mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
feat: initial VPS monitoring platform
- FastAPI backend with auth, server CRUD, metrics collection - Web dashboard with dark theme, server cards, SSH terminal (xterm.js) - Telegram bot with inline keyboards, server management, reboot - Telegram Mini App for mobile monitoring - Background monitoring via asyncssh (CPU, RAM, Disk, Network, Uptime) - Alert system with configurable thresholds - WebSocket SSH terminal in browser - One-command install script - Systemd service configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
87
README.md
Normal file
87
README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 🖥 VPS Monitoring
|
||||||
|
|
||||||
|
Платформа мониторинга VPS серверов с веб-интерфейсом, SSH терминалом и Telegram ботом.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Мониторинг** — CPU, RAM, Disk, Network, Uptime, Load Average
|
||||||
|
- **SSH терминал** — подключение к серверам прямо из браузера
|
||||||
|
- **Telegram бот** — статус, алерты, управление (перезагрузка)
|
||||||
|
- **Telegram Mini App** — мобильная панель прямо в Telegram
|
||||||
|
- **Алерты** — уведомления при превышении порогов (CPU/RAM/Disk)
|
||||||
|
- **Управление** — добавление/удаление серверов, перезагрузка
|
||||||
|
- **Авторизация** — защита паролем с возможностью смены
|
||||||
|
|
||||||
|
## Установка (одна команда)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Ubuntu 20.04+ / Debian 11+
|
||||||
|
- Python 3.10+
|
||||||
|
- 512 MB RAM минимум
|
||||||
|
|
||||||
|
## После установки
|
||||||
|
|
||||||
|
- **Панель:** `http://YOUR_IP:8000`
|
||||||
|
- **Логин:** `admin` / `admin`
|
||||||
|
- **Telegram:** бот запускается автоматически
|
||||||
|
|
||||||
|
## Управление
|
||||||
|
|
||||||
|
```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_CHAT_ID` | ID чата для алертов |
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
89
install.sh
Executable file
89
install.sh
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# VPS Monitoring — One-command installer
|
||||||
|
# Usage: curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | bash
|
||||||
|
|
||||||
|
echo "================================================"
|
||||||
|
echo " 🖥 VPS Monitoring — Установка"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
APP_DIR="/opt/vps-monitoring"
|
||||||
|
REPO_URL="https://github.com/andrey271192/vps_monitoring.git"
|
||||||
|
SERVICE_NAME="vps-monitoring"
|
||||||
|
|
||||||
|
# Check root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "❌ Запустите от root: sudo bash install.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Обновление системы..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-venv python3-pip git curl > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Clone or update repo
|
||||||
|
if [ -d "$APP_DIR" ]; then
|
||||||
|
echo "📂 Обновление существующей установки..."
|
||||||
|
cd "$APP_DIR"
|
||||||
|
git pull --ff-only || true
|
||||||
|
else
|
||||||
|
echo "📥 Клонирование репозитория..."
|
||||||
|
git clone "$REPO_URL" "$APP_DIR"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Python virtual environment
|
||||||
|
echo "🐍 Настройка Python окружения..."
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q --upgrade pip
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# Setup environment variables
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "⚙️ Создание конфигурации..."
|
||||||
|
cat > .env << 'ENVEOF'
|
||||||
|
TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
|
ENVEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
echo "🔧 Установка systemd сервиса..."
|
||||||
|
cp systemd/vps-monitoring.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $SERVICE_NAME
|
||||||
|
systemctl restart $SERVICE_NAME
|
||||||
|
|
||||||
|
# Firewall (allow 8000)
|
||||||
|
if command -v ufw &> /dev/null; then
|
||||||
|
ufw allow 8000/tcp > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get server IP
|
||||||
|
SERVER_IP=$(curl -s ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================================================"
|
||||||
|
echo " ✅ Установка завершена!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo " 🌐 Панель: http://${SERVER_IP}:8000"
|
||||||
|
echo " 👤 Логин: admin"
|
||||||
|
echo " 🔑 Пароль: admin"
|
||||||
|
echo ""
|
||||||
|
echo " 📱 Telegram бот активен"
|
||||||
|
echo " 🔄 Авто-обновление: systemctl restart $SERVICE_NAME"
|
||||||
|
echo ""
|
||||||
|
echo " 📋 Команды управления:"
|
||||||
|
echo " systemctl status $SERVICE_NAME"
|
||||||
|
echo " systemctl restart $SERVICE_NAME"
|
||||||
|
echo " journalctl -u $SERVICE_NAME -f"
|
||||||
|
echo ""
|
||||||
|
echo " ⚠️ Смените пароль в настройках панели!"
|
||||||
|
echo "================================================"
|
||||||
14
requirements.txt
Normal file
14
requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
python-multipart==0.0.9
|
||||||
|
jinja2==3.1.4
|
||||||
|
paramiko==3.4.0
|
||||||
|
asyncssh==2.14.2
|
||||||
|
websockets==12.0
|
||||||
|
python-telegram-bot==21.5
|
||||||
|
psutil==6.0.0
|
||||||
|
aiohttp==3.10.0
|
||||||
|
pydantic==2.9.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
apscheduler==3.10.4
|
||||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
0
server/api/__init__.py
Normal file
0
server/api/__init__.py
Normal file
51
server/api/auth_routes.py
Normal file
51
server/api/auth_routes.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
|
from server.auth import verify_password, create_token, require_auth
|
||||||
|
from server.config import load_settings, save_settings
|
||||||
|
from server.models import LoginForm, SettingsUpdate
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/login")
|
||||||
|
async def login(form: LoginForm):
|
||||||
|
settings = load_settings()
|
||||||
|
if (
|
||||||
|
form.username == settings["admin_login"]
|
||||||
|
and form.password == settings["admin_password"]
|
||||||
|
):
|
||||||
|
token = create_token(form.username)
|
||||||
|
response = JSONResponse({"status": "ok"})
|
||||||
|
response.set_cookie("access_token", token, httponly=True, max_age=86400)
|
||||||
|
return response
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/logout")
|
||||||
|
async def logout():
|
||||||
|
response = RedirectResponse("/login", status_code=302)
|
||||||
|
response.delete_cookie("access_token")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/settings")
|
||||||
|
async def get_settings(request: Request):
|
||||||
|
require_auth(request)
|
||||||
|
settings = load_settings()
|
||||||
|
# Mask sensitive data
|
||||||
|
safe = {**settings}
|
||||||
|
if safe.get("telegram_bot_token"):
|
||||||
|
safe["telegram_bot_token"] = safe["telegram_bot_token"][:10] + "..."
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/settings")
|
||||||
|
async def update_settings(update: SettingsUpdate, request: Request):
|
||||||
|
require_auth(request)
|
||||||
|
settings = load_settings()
|
||||||
|
for key, val in update.model_dump(exclude_unset=True).items():
|
||||||
|
if val is not None:
|
||||||
|
settings[key] = val
|
||||||
|
save_settings(settings)
|
||||||
|
return {"status": "ok"}
|
||||||
105
server/api/servers.py
Normal file
105
server/api/servers.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
|
||||||
|
from server.auth import require_auth
|
||||||
|
from server.config import load_servers, save_servers
|
||||||
|
from server.models import ServerCreate, ServerUpdate
|
||||||
|
from server.services.monitor import get_metrics, get_all_metrics
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/servers", tags=["servers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_servers(request: Request, user: str = Depends(require_auth)):
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
result = []
|
||||||
|
for srv in servers:
|
||||||
|
srv_data = {**srv}
|
||||||
|
srv_data["metrics"] = metrics.get(srv["host"], {})
|
||||||
|
if "password" in srv_data:
|
||||||
|
srv_data["password"] = "***"
|
||||||
|
result.append(srv_data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def add_server(server: ServerCreate, user: str = Depends(require_auth)):
|
||||||
|
servers = load_servers()
|
||||||
|
|
||||||
|
# Check duplicate
|
||||||
|
for s in servers:
|
||||||
|
if s["host"] == server.host:
|
||||||
|
raise HTTPException(400, f"Server {server.host} already exists")
|
||||||
|
|
||||||
|
new_server = {
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"name": server.name,
|
||||||
|
"host": server.host,
|
||||||
|
"port": server.port,
|
||||||
|
"username": server.username,
|
||||||
|
"password": server.password,
|
||||||
|
"ssh_key": server.ssh_key,
|
||||||
|
"description": server.description,
|
||||||
|
}
|
||||||
|
servers.append(new_server)
|
||||||
|
save_servers(servers)
|
||||||
|
return {"status": "ok", "server": {**new_server, "password": "***"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{server_id}")
|
||||||
|
async def update_server(server_id: str, update: ServerUpdate, user: str = Depends(require_auth)):
|
||||||
|
servers = load_servers()
|
||||||
|
for i, srv in enumerate(servers):
|
||||||
|
if srv.get("id") == server_id or srv["host"] == server_id:
|
||||||
|
for key, val in update.model_dump(exclude_unset=True).items():
|
||||||
|
servers[i][key] = val
|
||||||
|
save_servers(servers)
|
||||||
|
return {"status": "ok"}
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{server_id}")
|
||||||
|
async def delete_server(server_id: str, user: str = Depends(require_auth)):
|
||||||
|
servers = load_servers()
|
||||||
|
servers = [s for s in servers if s.get("id") != server_id and s["host"] != server_id]
|
||||||
|
save_servers(servers)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{server_id}/metrics")
|
||||||
|
async def server_metrics(server_id: str, user: str = Depends(require_auth)):
|
||||||
|
servers = load_servers()
|
||||||
|
for srv in servers:
|
||||||
|
if srv.get("id") == server_id or srv["host"] == server_id:
|
||||||
|
return get_metrics(srv["host"])
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{server_id}/reboot")
|
||||||
|
async def reboot_server(server_id: str, user: str = Depends(require_auth)):
|
||||||
|
from server.services.ssh_manager import execute_command
|
||||||
|
|
||||||
|
servers = load_servers()
|
||||||
|
for srv in servers:
|
||||||
|
if srv.get("id") == server_id or srv["host"] == server_id:
|
||||||
|
result = await execute_command(srv, "reboot")
|
||||||
|
return {"status": "ok", "result": result}
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{server_id}/exec")
|
||||||
|
async def exec_command(server_id: str, request: Request, user: str = Depends(require_auth)):
|
||||||
|
from server.services.ssh_manager import execute_command
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
command = body.get("command", "")
|
||||||
|
if not command:
|
||||||
|
raise HTTPException(400, "Command required")
|
||||||
|
|
||||||
|
servers = load_servers()
|
||||||
|
for srv in servers:
|
||||||
|
if srv.get("id") == server_id or srv["host"] == server_id:
|
||||||
|
result = await execute_command(srv, command)
|
||||||
|
return result
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
83
server/api/ssh_ws.py
Normal file
83
server/api/ssh_ws.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
from server.auth import verify_token
|
||||||
|
from server.config import load_servers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/ssh/{server_id}")
|
||||||
|
async def ssh_websocket(websocket: WebSocket, server_id: str):
|
||||||
|
"""WebSocket SSH terminal."""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Auth check
|
||||||
|
token = websocket.cookies.get("access_token")
|
||||||
|
if not verify_token(token):
|
||||||
|
await websocket.send_text("Authentication required")
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
servers = load_servers()
|
||||||
|
server = None
|
||||||
|
for srv in servers:
|
||||||
|
if srv.get("id") == server_id or srv["host"] == server_id:
|
||||||
|
server = srv
|
||||||
|
break
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
await websocket.send_text("Server not found")
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn_kwargs = {
|
||||||
|
"host": server["host"],
|
||||||
|
"port": server.get("port", 22),
|
||||||
|
"username": server.get("username", "root"),
|
||||||
|
"known_hosts": None,
|
||||||
|
"connect_timeout": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.get("password"):
|
||||||
|
conn_kwargs["password"] = server["password"]
|
||||||
|
elif server.get("ssh_key"):
|
||||||
|
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||||
|
|
||||||
|
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||||
|
process = await conn.create_process(
|
||||||
|
term_type="xterm-256color",
|
||||||
|
term_size=(120, 40),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_output():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await process.stdout.read(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
await websocket.send_text(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
read_task = asyncio.create_task(read_output())
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
process.stdin.write(data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
read_task.cancel()
|
||||||
|
process.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_text(f"\r\nConnection error: {e}\r\n")
|
||||||
|
await websocket.close()
|
||||||
43
server/auth.py
Normal file
43
server/auth.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Request, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from server.config import load_settings
|
||||||
|
|
||||||
|
SECRET_KEY = "vps-monitoring-secret-key-change-me-in-production"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
TOKEN_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return plain_password == hashed_password
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(username: str) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS)
|
||||||
|
return jwt.encode({"sub": username, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> str | None:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload.get("sub")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> str | None:
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return verify_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(request: Request):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return user
|
||||||
47
server/config.py
Normal file
47
server/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
SERVERS_FILE = DATA_DIR / "servers.json"
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
METRICS_FILE = DATA_DIR / "metrics.json"
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"admin_login": "admin",
|
||||||
|
"admin_password": "admin",
|
||||||
|
"telegram_bot_token": os.getenv("TELEGRAM_BOT_TOKEN", ""),
|
||||||
|
"telegram_chat_id": os.getenv("TELEGRAM_CHAT_ID", ""),
|
||||||
|
"monitor_interval": 60,
|
||||||
|
"alert_cpu_threshold": 90,
|
||||||
|
"alert_ram_threshold": 90,
|
||||||
|
"alert_disk_threshold": 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings():
|
||||||
|
if SETTINGS_FILE.exists():
|
||||||
|
with open(SETTINGS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
save_settings(DEFAULT_SETTINGS)
|
||||||
|
return DEFAULT_SETTINGS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(settings):
|
||||||
|
with open(SETTINGS_FILE, "w") as f:
|
||||||
|
json.dump(settings, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def load_servers():
|
||||||
|
if SERVERS_FILE.exists():
|
||||||
|
with open(SERVERS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_servers(servers):
|
||||||
|
with open(SERVERS_FILE, "w") as f:
|
||||||
|
json.dump(servers, f, indent=2, ensure_ascii=False)
|
||||||
99
server/main.py
Normal file
99
server/main.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
# Add parent to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from server.auth import get_current_user
|
||||||
|
from server.api.servers import router as servers_router
|
||||||
|
from server.api.auth_routes import router as auth_router
|
||||||
|
from server.api.ssh_ws import router as ssh_router
|
||||||
|
from server.services.monitor import monitor_loop
|
||||||
|
from server.services.telegram_bot import start_bot, stop_bot
|
||||||
|
from server.services.alerter import check_alerts
|
||||||
|
from server.config import load_settings
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
monitor_task = None
|
||||||
|
alert_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def alert_loop():
|
||||||
|
"""Periodic alert checking."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await check_alerts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Alert check error: {e}")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global monitor_task, alert_task
|
||||||
|
# Start background tasks
|
||||||
|
monitor_task = asyncio.create_task(monitor_loop())
|
||||||
|
alert_task = asyncio.create_task(alert_loop())
|
||||||
|
await start_bot()
|
||||||
|
logger.info("VPS Monitoring started")
|
||||||
|
yield
|
||||||
|
# Cleanup
|
||||||
|
if monitor_task:
|
||||||
|
monitor_task.cancel()
|
||||||
|
if alert_task:
|
||||||
|
alert_task.cancel()
|
||||||
|
await stop_bot()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="VPS Monitoring", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
||||||
|
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(servers_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(ssh_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if user:
|
||||||
|
return RedirectResponse("/")
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/telegram-app", response_class=HTMLResponse)
|
||||||
|
async def telegram_app(request: Request):
|
||||||
|
return templates.TemplateResponse("telegram_app.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("server.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
54
server/models.py
Normal file
54
server/models.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ServerCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
port: int = 22
|
||||||
|
username: str = "root"
|
||||||
|
password: Optional[str] = None
|
||||||
|
ssh_key: Optional[str] = None
|
||||||
|
description: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ServerUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
ssh_key: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
admin_login: Optional[str] = None
|
||||||
|
admin_password: Optional[str] = None
|
||||||
|
telegram_bot_token: Optional[str] = None
|
||||||
|
telegram_chat_id: Optional[str] = None
|
||||||
|
monitor_interval: Optional[int] = None
|
||||||
|
alert_cpu_threshold: Optional[int] = None
|
||||||
|
alert_ram_threshold: Optional[int] = None
|
||||||
|
alert_disk_threshold: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServerMetrics(BaseModel):
|
||||||
|
cpu_percent: float = 0
|
||||||
|
ram_percent: float = 0
|
||||||
|
ram_used_mb: float = 0
|
||||||
|
ram_total_mb: float = 0
|
||||||
|
disk_percent: float = 0
|
||||||
|
disk_used_gb: float = 0
|
||||||
|
disk_total_gb: float = 0
|
||||||
|
network_in_mb: float = 0
|
||||||
|
network_out_mb: float = 0
|
||||||
|
uptime: str = ""
|
||||||
|
load_average: str = ""
|
||||||
|
online: bool = False
|
||||||
|
last_check: str = ""
|
||||||
0
server/services/__init__.py
Normal file
0
server/services/__init__.py
Normal file
56
server/services/alerter.py
Normal file
56
server/services/alerter.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from server.config import load_settings, load_servers
|
||||||
|
from server.services.monitor import get_all_metrics
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
previous_states: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_alerts():
|
||||||
|
"""Check metrics against thresholds and send alerts."""
|
||||||
|
from server.services.telegram_bot import send_alert
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
|
cpu_threshold = settings.get("alert_cpu_threshold", 90)
|
||||||
|
ram_threshold = settings.get("alert_ram_threshold", 90)
|
||||||
|
disk_threshold = settings.get("alert_disk_threshold", 90)
|
||||||
|
|
||||||
|
for srv in servers:
|
||||||
|
host = srv["host"]
|
||||||
|
m = metrics.get(host, {})
|
||||||
|
name = srv.get("name", host)
|
||||||
|
|
||||||
|
current_online = m.get("online", False)
|
||||||
|
prev_online = previous_states.get(host)
|
||||||
|
|
||||||
|
# Online/Offline state change
|
||||||
|
if prev_online is not None and prev_online != current_online:
|
||||||
|
if current_online:
|
||||||
|
await send_alert(f"🟢 **{name}** ({host}) — снова онлайн")
|
||||||
|
else:
|
||||||
|
await send_alert(f"🔴 **{name}** ({host}) — OFFLINE!")
|
||||||
|
|
||||||
|
previous_states[host] = current_online
|
||||||
|
|
||||||
|
if not current_online:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Threshold alerts
|
||||||
|
cpu = m.get("cpu_percent", 0)
|
||||||
|
if cpu >= cpu_threshold:
|
||||||
|
await send_alert(f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)")
|
||||||
|
|
||||||
|
ram = m.get("ram_percent", 0)
|
||||||
|
if ram >= ram_threshold:
|
||||||
|
await send_alert(f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)")
|
||||||
|
|
||||||
|
disk = m.get("disk_percent", 0)
|
||||||
|
if disk >= disk_threshold:
|
||||||
|
await send_alert(f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)")
|
||||||
163
server/services/monitor.py
Normal file
163
server/services/monitor.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
from server.config import load_servers, load_settings, DATA_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
metrics_cache: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_server_metrics(server: dict) -> dict:
|
||||||
|
"""Connect to server via SSH and collect system metrics."""
|
||||||
|
metrics = {
|
||||||
|
"cpu_percent": 0,
|
||||||
|
"ram_percent": 0,
|
||||||
|
"ram_used_mb": 0,
|
||||||
|
"ram_total_mb": 0,
|
||||||
|
"disk_percent": 0,
|
||||||
|
"disk_used_gb": 0,
|
||||||
|
"disk_total_gb": 0,
|
||||||
|
"network_in_mb": 0,
|
||||||
|
"network_out_mb": 0,
|
||||||
|
"uptime": "",
|
||||||
|
"load_average": "",
|
||||||
|
"online": False,
|
||||||
|
"last_check": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn_kwargs = {
|
||||||
|
"host": server["host"],
|
||||||
|
"port": server.get("port", 22),
|
||||||
|
"username": server.get("username", "root"),
|
||||||
|
"known_hosts": None,
|
||||||
|
"connect_timeout": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.get("password"):
|
||||||
|
conn_kwargs["password"] = server["password"]
|
||||||
|
elif server.get("ssh_key"):
|
||||||
|
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||||
|
|
||||||
|
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||||
|
# CPU usage
|
||||||
|
result = await conn.run(
|
||||||
|
"top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1",
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
try:
|
||||||
|
metrics["cpu_percent"] = round(float(result.stdout.strip()), 1)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
result = await conn.run(
|
||||||
|
"free -m | awk 'NR==2{printf \"%s %s %s\", $2, $3, $3/$2*100}'",
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
parts = result.stdout.strip().split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
metrics["ram_total_mb"] = float(parts[0])
|
||||||
|
metrics["ram_used_mb"] = float(parts[1])
|
||||||
|
metrics["ram_percent"] = round(float(parts[2]), 1)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Disk
|
||||||
|
result = await conn.run(
|
||||||
|
"df -BG / | awk 'NR==2{print $2, $3, $5}'",
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
parts = result.stdout.strip().replace("G", "").split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
metrics["disk_total_gb"] = float(parts[0])
|
||||||
|
metrics["disk_used_gb"] = float(parts[1])
|
||||||
|
metrics["disk_percent"] = float(parts[2].replace("%", ""))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Network
|
||||||
|
result = await conn.run(
|
||||||
|
"cat /proc/net/dev | awk 'NR>2{rx+=$2; tx+=$10} END{printf \"%.0f %.0f\", rx/1048576, tx/1048576}'",
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
parts = result.stdout.strip().split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
metrics["network_in_mb"] = float(parts[0])
|
||||||
|
metrics["network_out_mb"] = float(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
result = await conn.run("uptime -p", check=False)
|
||||||
|
if result.stdout.strip():
|
||||||
|
metrics["uptime"] = result.stdout.strip()
|
||||||
|
|
||||||
|
# Load average
|
||||||
|
result = await conn.run("cat /proc/loadavg | awk '{print $1, $2, $3}'", check=False)
|
||||||
|
if result.stdout.strip():
|
||||||
|
metrics["load_average"] = result.stdout.strip()
|
||||||
|
|
||||||
|
metrics["online"] = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to collect metrics from {server.get('name', server['host'])}: {e}")
|
||||||
|
metrics["online"] = False
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_loop():
|
||||||
|
"""Background monitoring loop."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
servers = load_servers()
|
||||||
|
interval = settings.get("monitor_interval", 60)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for srv in servers:
|
||||||
|
tasks.append(collect_server_metrics(srv))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for srv, result in zip(servers, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
metrics_cache[srv["host"]] = {
|
||||||
|
"online": False,
|
||||||
|
"last_check": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
metrics_cache[srv["host"]] = result
|
||||||
|
|
||||||
|
# Save metrics to file
|
||||||
|
metrics_file = DATA_DIR / "metrics.json"
|
||||||
|
with open(metrics_file, "w") as f:
|
||||||
|
json.dump(metrics_cache, f, indent=2)
|
||||||
|
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Monitor loop error: {e}")
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
def get_metrics(host: str) -> dict:
|
||||||
|
return metrics_cache.get(host, {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_metrics() -> dict:
|
||||||
|
return metrics_cache.copy()
|
||||||
69
server/services/ssh_manager.py
Normal file
69
server/services/ssh_manager.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
active_sessions: Dict[str, asyncssh.SSHClientConnection] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ssh_session(server: dict) -> Optional[asyncssh.SSHClientConnection]:
|
||||||
|
"""Create SSH connection to server."""
|
||||||
|
try:
|
||||||
|
conn_kwargs = {
|
||||||
|
"host": server["host"],
|
||||||
|
"port": server.get("port", 22),
|
||||||
|
"username": server.get("username", "root"),
|
||||||
|
"known_hosts": None,
|
||||||
|
"connect_timeout": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.get("password"):
|
||||||
|
conn_kwargs["password"] = server["password"]
|
||||||
|
elif server.get("ssh_key"):
|
||||||
|
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||||
|
|
||||||
|
conn = await asyncssh.connect(**conn_kwargs)
|
||||||
|
active_sessions[server["host"]] = conn
|
||||||
|
return conn
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH connection failed to {server['host']}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_command(server: dict, command: str) -> dict:
|
||||||
|
"""Execute command on remote server."""
|
||||||
|
try:
|
||||||
|
conn_kwargs = {
|
||||||
|
"host": server["host"],
|
||||||
|
"port": server.get("port", 22),
|
||||||
|
"username": server.get("username", "root"),
|
||||||
|
"known_hosts": None,
|
||||||
|
"connect_timeout": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.get("password"):
|
||||||
|
conn_kwargs["password"] = server["password"]
|
||||||
|
elif server.get("ssh_key"):
|
||||||
|
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||||
|
|
||||||
|
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||||
|
result = await conn.run(command, check=False, timeout=30)
|
||||||
|
return {
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
"exit_code": result.exit_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
||||||
|
|
||||||
|
|
||||||
|
async def close_session(host: str):
|
||||||
|
"""Close SSH session."""
|
||||||
|
conn = active_sessions.pop(host, None)
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
295
server/services/telegram_bot.py
Normal file
295
server/services/telegram_bot.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
CommandHandler,
|
||||||
|
CallbackQueryHandler,
|
||||||
|
ContextTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
from server.config import load_settings, load_servers
|
||||||
|
from server.services.monitor import get_all_metrics, collect_server_metrics
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bot_app: Optional[Application] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
settings = load_settings()
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
|
online = sum(1 for s in servers if metrics.get(s["host"], {}).get("online", False))
|
||||||
|
offline = len(servers) - online
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"🖥 **VPS Monitoring**\n\n"
|
||||||
|
f"Серверов: {len(servers)}\n"
|
||||||
|
f"🟢 Online: {online}\n"
|
||||||
|
f"🔴 Offline: {offline}\n\n"
|
||||||
|
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")],
|
||||||
|
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")],
|
||||||
|
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add Web App button if available
|
||||||
|
webapp_url = f"http://{_get_host()}:8000/telegram-app"
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("📱 Открыть панель", web_app=WebAppInfo(url=webapp_url))
|
||||||
|
])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode="Markdown")
|
||||||
|
|
||||||
|
|
||||||
|
async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
await query.edit_message_text("Нет добавленных серверов")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "📊 **Статус серверов:**\n\n"
|
||||||
|
for srv in servers:
|
||||||
|
m = metrics.get(srv["host"], {})
|
||||||
|
status = "🟢" if m.get("online") else "🔴"
|
||||||
|
cpu = m.get("cpu_percent", 0)
|
||||||
|
ram = m.get("ram_percent", 0)
|
||||||
|
disk = m.get("disk_percent", 0)
|
||||||
|
text += (
|
||||||
|
f"{status} **{srv['name']}** ({srv['host']})\n"
|
||||||
|
f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("🔄 Обновить", callback_data="status")],
|
||||||
|
[InlineKeyboardButton("◀️ Назад", callback_data="back")],
|
||||||
|
]
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def manage_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
servers = load_servers()
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
for i, srv in enumerate(servers):
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton(f"🖥 {srv['name']}", callback_data=f"server_{i}")
|
||||||
|
])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("◀️ Назад", callback_data="back")])
|
||||||
|
await query.edit_message_text(
|
||||||
|
"⚙️ **Управление серверами:**\nВыберите сервер:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(keyboard),
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def server_action_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
data = query.data
|
||||||
|
if data.startswith("server_"):
|
||||||
|
idx = int(data.split("_")[1])
|
||||||
|
servers = load_servers()
|
||||||
|
if idx >= len(servers):
|
||||||
|
await query.edit_message_text("Сервер не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
srv = servers[idx]
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
m = metrics.get(srv["host"], {})
|
||||||
|
|
||||||
|
status = "🟢 Online" if m.get("online") else "🔴 Offline"
|
||||||
|
text = (
|
||||||
|
f"🖥 **{srv['name']}**\n"
|
||||||
|
f"Host: `{srv['host']}`\n"
|
||||||
|
f"Status: {status}\n"
|
||||||
|
f"CPU: {m.get('cpu_percent', 0)}%\n"
|
||||||
|
f"RAM: {m.get('ram_percent', 0)}% ({m.get('ram_used_mb', 0):.0f}/{m.get('ram_total_mb', 0):.0f} MB)\n"
|
||||||
|
f"Disk: {m.get('disk_percent', 0)}% ({m.get('disk_used_gb', 0):.1f}/{m.get('disk_total_gb', 0):.1f} GB)\n"
|
||||||
|
f"Uptime: {m.get('uptime', 'N/A')}\n"
|
||||||
|
f"Load: {m.get('load_average', 'N/A')}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("🔄 Перезагрузить", callback_data=f"reboot_{idx}")],
|
||||||
|
[InlineKeyboardButton("📊 Обновить метрики", callback_data=f"refresh_{idx}")],
|
||||||
|
[InlineKeyboardButton("◀️ Назад", callback_data="manage")],
|
||||||
|
]
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data.startswith("reboot_"):
|
||||||
|
idx = int(data.split("_")[1])
|
||||||
|
servers = load_servers()
|
||||||
|
if idx >= len(servers):
|
||||||
|
return
|
||||||
|
|
||||||
|
srv = servers[idx]
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("✅ Да, перезагрузить", callback_data=f"confirm_reboot_{idx}")],
|
||||||
|
[InlineKeyboardButton("❌ Отмена", callback_data=f"server_{idx}")],
|
||||||
|
]
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"⚠️ Перезагрузить сервер **{srv['name']}** ({srv['host']})?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(keyboard),
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data.startswith("confirm_reboot_"):
|
||||||
|
idx = int(data.split("_")[2])
|
||||||
|
servers = load_servers()
|
||||||
|
if idx >= len(servers):
|
||||||
|
return
|
||||||
|
|
||||||
|
srv = servers[idx]
|
||||||
|
try:
|
||||||
|
import asyncssh
|
||||||
|
conn_kwargs = {
|
||||||
|
"host": srv["host"],
|
||||||
|
"port": srv.get("port", 22),
|
||||||
|
"username": srv.get("username", "root"),
|
||||||
|
"known_hosts": None,
|
||||||
|
}
|
||||||
|
if srv.get("password"):
|
||||||
|
conn_kwargs["password"] = srv["password"]
|
||||||
|
|
||||||
|
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||||
|
await conn.run("reboot", check=False)
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"✅ Команда перезагрузки отправлена на **{srv['name']}**",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"❌ Ошибка: {e}",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data.startswith("refresh_"):
|
||||||
|
idx = int(data.split("_")[1])
|
||||||
|
servers = load_servers()
|
||||||
|
if idx >= len(servers):
|
||||||
|
return
|
||||||
|
|
||||||
|
srv = servers[idx]
|
||||||
|
await query.edit_message_text("⏳ Собираю метрики...")
|
||||||
|
|
||||||
|
from server.services.monitor import metrics_cache
|
||||||
|
result = await collect_server_metrics(srv)
|
||||||
|
metrics_cache[srv["host"]] = result
|
||||||
|
|
||||||
|
# Re-show server info
|
||||||
|
context.user_data["refresh_target"] = idx
|
||||||
|
await server_action_callback(update, context)
|
||||||
|
|
||||||
|
elif data == "refresh":
|
||||||
|
await status_callback(update, context)
|
||||||
|
|
||||||
|
elif data == "back":
|
||||||
|
# Simulate /start
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
online = sum(1 for s in servers if metrics.get(s["host"], {}).get("online", False))
|
||||||
|
offline = len(servers) - online
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"🖥 **VPS Monitoring**\n\n"
|
||||||
|
f"Серверов: {len(servers)}\n"
|
||||||
|
f"🟢 Online: {online}\n"
|
||||||
|
f"🔴 Offline: {offline}\n\n"
|
||||||
|
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")],
|
||||||
|
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")],
|
||||||
|
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")],
|
||||||
|
]
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_alert(message: str):
|
||||||
|
"""Send alert to Telegram chat."""
|
||||||
|
settings = load_settings()
|
||||||
|
token = settings.get("telegram_bot_token")
|
||||||
|
chat_id = settings.get("telegram_chat_id")
|
||||||
|
|
||||||
|
if not token or not chat_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if bot_app and bot_app.bot:
|
||||||
|
await bot_app.bot.send_message(chat_id=chat_id, text=message, parse_mode="Markdown")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Telegram alert: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_host():
|
||||||
|
"""Get public host for web app URL."""
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
return "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
async def start_bot():
|
||||||
|
"""Initialize and start the Telegram bot."""
|
||||||
|
global bot_app
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
token = settings.get("telegram_bot_token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_app = Application.builder().token(token).build()
|
||||||
|
|
||||||
|
bot_app.add_handler(CommandHandler("start", start_command))
|
||||||
|
bot_app.add_handler(CallbackQueryHandler(status_callback, pattern="^status$"))
|
||||||
|
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$)"))
|
||||||
|
|
||||||
|
await bot_app.initialize()
|
||||||
|
await bot_app.start()
|
||||||
|
await bot_app.updater.start_polling(drop_pending_updates=True)
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_bot():
|
||||||
|
"""Stop the Telegram bot."""
|
||||||
|
global bot_app
|
||||||
|
if bot_app:
|
||||||
|
await bot_app.updater.stop()
|
||||||
|
await bot_app.stop()
|
||||||
|
await bot_app.shutdown()
|
||||||
|
bot_app = None
|
||||||
429
server/static/css/style.css
Normal file
429
server/static/css/style.css
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0f0f23;
|
||||||
|
--bg-secondary: #1a1a3e;
|
||||||
|
--bg-card: #1e1e4a;
|
||||||
|
--text-primary: #e8e8ff;
|
||||||
|
--text-secondary: #a0a0cc;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--success: #4ade80;
|
||||||
|
--danger: #f87171;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Page */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a3e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 32px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Bar */
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item.online .stat-value { color: var(--success); }
|
||||||
|
.stat-item.offline .stat-value { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .btn-primary {
|
||||||
|
width: auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server Grid */
|
||||||
|
.servers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-header .name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-header .host {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item .value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item .value.warn { color: var(--warning); }
|
||||||
|
.metric-item .value.crit { color: var(--danger); }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar .fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar .fill.ok { background: var(--success); }
|
||||||
|
.progress-bar .fill.warn { background: var(--warning); }
|
||||||
|
.progress-bar .fill.crit { background: var(--danger); }
|
||||||
|
|
||||||
|
.server-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-actions button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card-actions button.danger:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content.modal-large {
|
||||||
|
max-width: 900px;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal */
|
||||||
|
#terminal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 60px);
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main { padding: 16px; }
|
||||||
|
.servers-grid { grid-template-columns: 1fr; }
|
||||||
|
.stats-bar { flex-direction: column; gap: 8px; }
|
||||||
|
header { padding: 12px 16px; }
|
||||||
|
}
|
||||||
339
server/static/js/app.js
Normal file
339
server/static/js/app.js
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
let servers = [];
|
||||||
|
let currentTerminal = null;
|
||||||
|
let currentWs = null;
|
||||||
|
|
||||||
|
// Load servers
|
||||||
|
async function loadServers() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/servers', {credentials: 'include'});
|
||||||
|
if (resp.status === 401) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
servers = await resp.json();
|
||||||
|
renderServers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServers() {
|
||||||
|
const grid = document.getElementById('servers-grid');
|
||||||
|
const online = servers.filter(s => s.metrics?.online).length;
|
||||||
|
const offline = servers.length - online;
|
||||||
|
|
||||||
|
document.getElementById('total-count').textContent = servers.length;
|
||||||
|
document.getElementById('online-count').textContent = online;
|
||||||
|
document.getElementById('offline-count').textContent = offline;
|
||||||
|
|
||||||
|
if (!servers.length) {
|
||||||
|
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет серверов. Нажмите "+ Добавить сервер"</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = servers.map(srv => {
|
||||||
|
const m = srv.metrics || {};
|
||||||
|
const isOnline = m.online;
|
||||||
|
const cpu = m.cpu_percent || 0;
|
||||||
|
const ram = m.ram_percent || 0;
|
||||||
|
const disk = m.disk_percent || 0;
|
||||||
|
|
||||||
|
const cpuClass = cpu > 90 ? 'crit' : cpu > 70 ? 'warn' : '';
|
||||||
|
const ramClass = ram > 90 ? 'crit' : ram > 70 ? 'warn' : '';
|
||||||
|
const diskClass = disk > 90 ? 'crit' : disk > 70 ? 'warn' : '';
|
||||||
|
|
||||||
|
const id = srv.id || srv.host;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="server-card" onclick="showServerDetail('${id}')">
|
||||||
|
<div class="server-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="name">${srv.name}</div>
|
||||||
|
<div class="host">${srv.host}:${srv.port || 22}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||||
|
${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">Disk</span>
|
||||||
|
<span class="value ${diskClass}">${disk}%</span>
|
||||||
|
<div class="progress-bar"><div class="fill ${diskClass || 'ok'}" style="width:${disk}%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных</div>'}
|
||||||
|
<div class="server-card-actions">
|
||||||
|
<button onclick="event.stopPropagation(); openSSH('${id}', '${srv.name}')">💻 SSH</button>
|
||||||
|
<button onclick="event.stopPropagation(); rebootServer('${id}')">🔄 Reboot</button>
|
||||||
|
<button class="danger" onclick="event.stopPropagation(); deleteServer('${id}')">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServerDetail(id) {
|
||||||
|
const srv = servers.find(s => (s.id || s.host) === id);
|
||||||
|
if (!srv) return;
|
||||||
|
|
||||||
|
const m = srv.metrics || {};
|
||||||
|
const modal = document.getElementById('serverDetailModal');
|
||||||
|
document.getElementById('detail-title').textContent = srv.name;
|
||||||
|
|
||||||
|
document.getElementById('detail-content').innerHTML = `
|
||||||
|
<div style="display:grid;gap:12px">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Host</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px;font-family:monospace">${srv.host}:${srv.port || 22}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status</label>
|
||||||
|
<div><span class="status-badge ${m.online ? 'online' : 'offline'}">${m.online ? 'Online' : 'Offline'}</span></div>
|
||||||
|
</div>
|
||||||
|
${m.online ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Uptime</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">${m.uptime || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Load Average</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">${m.load_average || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>RAM</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">${(m.ram_used_mb||0).toFixed(0)} / ${(m.ram_total_mb||0).toFixed(0)} MB (${m.ram_percent||0}%)</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Disk</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">${(m.disk_used_gb||0).toFixed(1)} / ${(m.disk_total_gb||0).toFixed(1)} GB (${m.disk_percent||0}%)</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Network</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">↓ ${(m.network_in_mb||0).toFixed(0)} MB / ↑ ${(m.network_out_mb||0).toFixed(0)} MB</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${srv.description ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Описание</label>
|
||||||
|
<div style="padding:8px;background:var(--bg-primary);border-radius:6px">${srv.description}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH Terminal
|
||||||
|
function openSSH(id, name) {
|
||||||
|
const modal = document.getElementById('sshModal');
|
||||||
|
document.getElementById('ssh-title').textContent = `SSH — ${name}`;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
const container = document.getElementById('terminal-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (typeof Terminal === 'undefined') {
|
||||||
|
container.innerHTML = '<div style="padding:20px;color:#f87171">xterm.js не загружен</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#e8e8ff',
|
||||||
|
cursor: '#6366f1',
|
||||||
|
},
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
});
|
||||||
|
|
||||||
|
term.open(container);
|
||||||
|
currentTerminal = term;
|
||||||
|
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/ssh/${id}`);
|
||||||
|
currentWs = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
term.write('\r\n\x1b[32mConnecting...\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
term.write(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
term.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData(data => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSSH() {
|
||||||
|
if (currentWs) {
|
||||||
|
currentWs.close();
|
||||||
|
currentWs = null;
|
||||||
|
}
|
||||||
|
if (currentTerminal) {
|
||||||
|
currentTerminal.dispose();
|
||||||
|
currentTerminal = null;
|
||||||
|
}
|
||||||
|
closeModal('sshModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server actions
|
||||||
|
async function rebootServer(id) {
|
||||||
|
if (!confirm('Перезагрузить сервер?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
|
||||||
|
alert('Команда перезагрузки отправлена');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id) {
|
||||||
|
if (!confirm('Удалить сервер из мониторинга?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
|
||||||
|
loadServers();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add server
|
||||||
|
function showAddServer() {
|
||||||
|
document.getElementById('addServerModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addServerForm')?.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) || 22,
|
||||||
|
username: form.username.value || 'root',
|
||||||
|
password: form.password.value,
|
||||||
|
description: form.description.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
closeModal('addServerModal');
|
||||||
|
form.reset();
|
||||||
|
loadServers();
|
||||||
|
} else {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert(err.detail || 'Ошибка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
async function showSettings() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings', {credentials: 'include'});
|
||||||
|
const settings = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('set_login').value = settings.admin_login || '';
|
||||||
|
document.getElementById('set_tg_token').value = settings.telegram_bot_token || '';
|
||||||
|
document.getElementById('set_tg_chat').value = settings.telegram_chat_id || '';
|
||||||
|
document.getElementById('set_interval').value = settings.monitor_interval || 60;
|
||||||
|
document.getElementById('set_cpu').value = settings.alert_cpu_threshold || 90;
|
||||||
|
document.getElementById('set_ram').value = settings.alert_ram_threshold || 90;
|
||||||
|
document.getElementById('set_disk').value = settings.alert_disk_threshold || 90;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
document.getElementById('settingsModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('settingsForm')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
if (form.admin_login.value) data.admin_login = form.admin_login.value;
|
||||||
|
if (form.admin_password.value) data.admin_password = form.admin_password.value;
|
||||||
|
if (form.telegram_bot_token.value) data.telegram_bot_token = form.telegram_bot_token.value;
|
||||||
|
if (form.telegram_chat_id.value) data.telegram_chat_id = form.telegram_chat_id.value;
|
||||||
|
if (form.monitor_interval.value) data.monitor_interval = parseInt(form.monitor_interval.value);
|
||||||
|
if (form.alert_cpu_threshold.value) data.alert_cpu_threshold = parseInt(form.alert_cpu_threshold.value);
|
||||||
|
if (form.alert_ram_threshold.value) data.alert_ram_threshold = parseInt(form.alert_ram_threshold.value);
|
||||||
|
if (form.alert_disk_threshold.value) data.alert_disk_threshold = parseInt(form.alert_disk_threshold.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
closeModal('settingsModal');
|
||||||
|
alert('Настройки сохранены');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
loadServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/api/logout', {method: 'POST', credentials: 'include'});
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside modal to close
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
if (modal.id === 'sshModal') {
|
||||||
|
closeSSH();
|
||||||
|
} else {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadServers();
|
||||||
|
setInterval(loadServers, 30000);
|
||||||
157
server/templates/dashboard.html
Normal file
157
server/templates/dashboard.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPS Monitoring</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.3.0/xterm.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>🖥 VPS Monitoring</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="btn-icon" onclick="refreshAll()" title="Обновить">🔄</button>
|
||||||
|
<button class="btn-icon" onclick="showSettings()" title="Настройки">⚙️</button>
|
||||||
|
<button class="btn-icon" onclick="logout()" title="Выход">🚪</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-item" id="stat-total">
|
||||||
|
<span class="stat-label">Всего</span>
|
||||||
|
<span class="stat-value" id="total-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item online" id="stat-online">
|
||||||
|
<span class="stat-label">Online</span>
|
||||||
|
<span class="stat-value" id="online-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item offline" id="stat-offline">
|
||||||
|
<span class="stat-label">Offline</span>
|
||||||
|
<span class="stat-value" id="offline-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-primary" onclick="showAddServer()">+ Добавить сервер</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="servers-grid" id="servers-grid">
|
||||||
|
<!-- Server cards rendered here -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add Server Modal -->
|
||||||
|
<div class="modal" id="addServerModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Добавить сервер</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('addServerModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="addServerForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" name="name" required placeholder="My Server">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IP / Host</label>
|
||||||
|
<input type="text" name="host" required placeholder="192.168.1.1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порт SSH</label>
|
||||||
|
<input type="number" name="port" value="22">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Логин</label>
|
||||||
|
<input type="text" name="username" value="root">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input type="password" name="password" placeholder="Пароль SSH">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Описание</label>
|
||||||
|
<input type="text" name="description" placeholder="Описание сервера">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div class="modal" id="settingsModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Настройки</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('settingsModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="settingsForm">
|
||||||
|
<h3>Авторизация</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Логин</label>
|
||||||
|
<input type="text" name="admin_login" id="set_login">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Новый пароль</label>
|
||||||
|
<input type="password" name="admin_password" id="set_password" placeholder="Оставьте пустым, чтобы не менять">
|
||||||
|
</div>
|
||||||
|
<h3>Telegram</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bot Token</label>
|
||||||
|
<input type="text" name="telegram_bot_token" id="set_tg_token">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Chat ID</label>
|
||||||
|
<input type="text" name="telegram_chat_id" id="set_tg_chat">
|
||||||
|
</div>
|
||||||
|
<h3>Мониторинг</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Интервал опроса (сек)</label>
|
||||||
|
<input type="number" name="monitor_interval" id="set_interval" value="60">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порог CPU (%)</label>
|
||||||
|
<input type="number" name="alert_cpu_threshold" id="set_cpu" value="90">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порог RAM (%)</label>
|
||||||
|
<input type="number" name="alert_ram_threshold" id="set_ram" value="90">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порог Disk (%)</label>
|
||||||
|
<input type="number" name="alert_disk_threshold" id="set_disk" value="90">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSH Terminal Modal -->
|
||||||
|
<div class="modal" id="sshModal" style="display:none">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="ssh-title">SSH Terminal</h2>
|
||||||
|
<button class="btn-close" onclick="closeSSH()">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Detail Modal -->
|
||||||
|
<div class="modal" id="serverDetailModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="detail-title">Server</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('serverDetailModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="detail-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.3.0/xterm.min.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
server/templates/login.html
Normal file
56
server/templates/login.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPS Monitoring — Вход</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>🖥 VPS Monitoring</h1>
|
||||||
|
<p>Панель мониторинга серверов</p>
|
||||||
|
</div>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Логин</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Войти</button>
|
||||||
|
<div id="error" class="error-msg" style="display:none"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username, password})
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = 'Неверный логин или пароль';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Ошибка подключения';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
255
server/templates/telegram_app.html
Normal file
255
server/templates/telegram_app.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPS Monitor</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--tg-theme-bg-color, #1a1a2e);
|
||||||
|
color: var(--tg-theme-text-color, #eee);
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #16213e);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.header .subtitle { opacity: 0.7; font-size: 13px; }
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #16213e);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card .value { font-size: 24px; font-weight: bold; }
|
||||||
|
.stat-card .label { font-size: 11px; opacity: 0.7; margin-top: 4px; }
|
||||||
|
.stat-card.online .value { color: #4ade80; }
|
||||||
|
.stat-card.offline .value { color: #f87171; }
|
||||||
|
.server-list { list-style: none; }
|
||||||
|
.server-item {
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #16213e);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.server-item:active { transform: scale(0.98); }
|
||||||
|
.server-info { flex: 1; }
|
||||||
|
.server-name { font-weight: 600; font-size: 15px; }
|
||||||
|
.server-host { font-size: 12px; opacity: 0.6; margin-top: 2px; }
|
||||||
|
.server-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.metric-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.metric-badge.warn { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
|
||||||
|
.metric-badge.crit { background: rgba(248, 113, 113, 0.2); color: #f87171; }
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.status-dot.online { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
.status-dot.offline { background: #f87171; box-shadow: 0 0 6px #f87171; }
|
||||||
|
.server-detail {
|
||||||
|
display: none;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #16213e);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.server-detail.active { display: block; }
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.detail-row:last-child { border: none; }
|
||||||
|
.detail-label { opacity: 0.7; font-size: 13px; }
|
||||||
|
.detail-value { font-weight: 500; font-size: 13px; }
|
||||||
|
.action-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--tg-theme-button-color, #0a84ff);
|
||||||
|
color: var(--tg-theme-button-text-color, #fff);
|
||||||
|
}
|
||||||
|
.action-btn.danger { background: #dc2626; }
|
||||||
|
.loading { text-align: center; padding: 40px; opacity: 0.5; }
|
||||||
|
.refresh-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--tg-theme-button-color, #0a84ff);
|
||||||
|
color: var(--tg-theme-button-text-color, #fff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🖥 VPS Monitor</h1>
|
||||||
|
<div class="subtitle">Мониторинг серверов</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value" id="total">-</div>
|
||||||
|
<div class="label">Всего</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card online">
|
||||||
|
<div class="value" id="online">-</div>
|
||||||
|
<div class="label">Online</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card offline">
|
||||||
|
<div class="value" id="offline">-</div>
|
||||||
|
<div class="label">Offline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="server-list" id="serverList">
|
||||||
|
<li class="loading">Загрузка...</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="refresh-btn" onclick="loadServers()">🔄 Обновить</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg) {
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
let servers = [];
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/servers', {credentials: 'include'});
|
||||||
|
if (resp.status === 401) {
|
||||||
|
document.getElementById('serverList').innerHTML =
|
||||||
|
'<li class="loading">Требуется авторизация в панели</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
servers = await resp.json();
|
||||||
|
renderServers();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('serverList').innerHTML =
|
||||||
|
'<li class="loading">Ошибка загрузки</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServers() {
|
||||||
|
const list = document.getElementById('serverList');
|
||||||
|
const online = servers.filter(s => s.metrics?.online).length;
|
||||||
|
const offline = servers.length - online;
|
||||||
|
|
||||||
|
document.getElementById('total').textContent = servers.length;
|
||||||
|
document.getElementById('online').textContent = online;
|
||||||
|
document.getElementById('offline').textContent = offline;
|
||||||
|
|
||||||
|
if (!servers.length) {
|
||||||
|
list.innerHTML = '<li class="loading">Нет серверов</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = servers.map((srv, i) => {
|
||||||
|
const m = srv.metrics || {};
|
||||||
|
const isOnline = m.online;
|
||||||
|
const cpu = m.cpu_percent || 0;
|
||||||
|
const ram = m.ram_percent || 0;
|
||||||
|
const disk = m.disk_percent || 0;
|
||||||
|
|
||||||
|
const cpuClass = cpu > 90 ? 'crit' : cpu > 70 ? 'warn' : '';
|
||||||
|
const ramClass = ram > 90 ? 'crit' : ram > 70 ? 'warn' : '';
|
||||||
|
const diskClass = disk > 90 ? 'crit' : disk > 70 ? 'warn' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li class="server-item" onclick="toggleDetail(${i})">
|
||||||
|
<div class="server-info">
|
||||||
|
<div class="server-name">${srv.name}</div>
|
||||||
|
<div class="server-host">${srv.host}</div>
|
||||||
|
${isOnline ? `
|
||||||
|
<div class="server-metrics">
|
||||||
|
<span class="metric-badge ${cpuClass}">CPU ${cpu}%</span>
|
||||||
|
<span class="metric-badge ${ramClass}">RAM ${ram}%</span>
|
||||||
|
<span class="metric-badge ${diskClass}">Disk ${disk}%</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="status-dot ${isOnline ? 'online' : 'offline'}"></div>
|
||||||
|
</li>
|
||||||
|
<div class="server-detail" id="detail-${i}">
|
||||||
|
<div class="detail-row"><span class="detail-label">Uptime</span><span class="detail-value">${m.uptime || 'N/A'}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Load</span><span class="detail-value">${m.load_average || 'N/A'}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">RAM</span><span class="detail-value">${(m.ram_used_mb||0).toFixed(0)} / ${(m.ram_total_mb||0).toFixed(0)} MB</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Disk</span><span class="detail-value">${(m.disk_used_gb||0).toFixed(1)} / ${(m.disk_total_gb||0).toFixed(1)} GB</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Network In</span><span class="detail-value">${(m.network_in_mb||0).toFixed(0)} MB</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Network Out</span><span class="detail-value">${(m.network_out_mb||0).toFixed(0)} MB</span></div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="action-btn" onclick="event.stopPropagation(); rebootServer('${srv.id || srv.host}')">🔄 Reboot</button>
|
||||||
|
<button class="action-btn danger" onclick="event.stopPropagation(); deleteServer('${srv.id || srv.host}')">🗑 Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDetail(idx) {
|
||||||
|
const el = document.getElementById(`detail-${idx}`);
|
||||||
|
el.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebootServer(id) {
|
||||||
|
if (!confirm('Перезагрузить сервер?')) return;
|
||||||
|
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
|
||||||
|
if (tg) tg.showAlert('Команда перезагрузки отправлена');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id) {
|
||||||
|
if (!confirm('Удалить сервер из мониторинга?')) return;
|
||||||
|
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
|
||||||
|
loadServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadServers();
|
||||||
|
setInterval(loadServers, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
systemd/vps-monitoring.service
Normal file
16
systemd/vps-monitoring.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=VPS Monitoring Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/vps-monitoring
|
||||||
|
Environment=TELEGRAM_BOT_TOKEN=8815672362:AAFGwxrWjSsjGuc9iBpX0aupKRoKkviS2NA
|
||||||
|
Environment=TELEGRAM_CHAT_ID=371010834
|
||||||
|
ExecStart=/opt/vps-monitoring/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user