From e6e88314d5660264f7420ad0270b34532628deb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Wed, 20 May 2026 05:47:28 +0300 Subject: [PATCH] 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 --- README.md | 87 ++++++ install.sh | 89 ++++++ requirements.txt | 14 + server/__init__.py | 0 server/api/__init__.py | 0 server/api/auth_routes.py | 51 ++++ server/api/servers.py | 105 +++++++ server/api/ssh_ws.py | 83 ++++++ server/auth.py | 43 +++ server/config.py | 47 ++++ server/main.py | 99 +++++++ server/models.py | 54 ++++ server/services/__init__.py | 0 server/services/alerter.py | 56 ++++ server/services/monitor.py | 163 +++++++++++ server/services/ssh_manager.py | 69 +++++ server/services/telegram_bot.py | 295 ++++++++++++++++++++ server/static/css/style.css | 429 +++++++++++++++++++++++++++++ server/static/js/app.js | 339 +++++++++++++++++++++++ server/templates/dashboard.html | 157 +++++++++++ server/templates/login.html | 56 ++++ server/templates/telegram_app.html | 255 +++++++++++++++++ systemd/vps-monitoring.service | 16 ++ 23 files changed, 2507 insertions(+) create mode 100644 README.md create mode 100755 install.sh create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/api/__init__.py create mode 100644 server/api/auth_routes.py create mode 100644 server/api/servers.py create mode 100644 server/api/ssh_ws.py create mode 100644 server/auth.py create mode 100644 server/config.py create mode 100644 server/main.py create mode 100644 server/models.py create mode 100644 server/services/__init__.py create mode 100644 server/services/alerter.py create mode 100644 server/services/monitor.py create mode 100644 server/services/ssh_manager.py create mode 100644 server/services/telegram_bot.py create mode 100644 server/static/css/style.css create mode 100644 server/static/js/app.js create mode 100644 server/templates/dashboard.html create mode 100644 server/templates/login.html create mode 100644 server/templates/telegram_app.html create mode 100644 systemd/vps-monitoring.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..820e327 --- /dev/null +++ b/README.md @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9612426 --- /dev/null +++ b/install.sh @@ -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 "================================================" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f3baa9f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/auth_routes.py b/server/api/auth_routes.py new file mode 100644 index 0000000..3fc799a --- /dev/null +++ b/server/api/auth_routes.py @@ -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"} diff --git a/server/api/servers.py b/server/api/servers.py new file mode 100644 index 0000000..dde2bfd --- /dev/null +++ b/server/api/servers.py @@ -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") diff --git a/server/api/ssh_ws.py b/server/api/ssh_ws.py new file mode 100644 index 0000000..f2b04f2 --- /dev/null +++ b/server/api/ssh_ws.py @@ -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() diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..ae410f5 --- /dev/null +++ b/server/auth.py @@ -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 diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..82847f2 --- /dev/null +++ b/server/config.py @@ -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) diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..7acea04 --- /dev/null +++ b/server/main.py @@ -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) diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..13e30d1 --- /dev/null +++ b/server/models.py @@ -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 = "" diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/alerter.py b/server/services/alerter.py new file mode 100644 index 0000000..54ee0c7 --- /dev/null +++ b/server/services/alerter.py @@ -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}%)") diff --git a/server/services/monitor.py b/server/services/monitor.py new file mode 100644 index 0000000..214be2d --- /dev/null +++ b/server/services/monitor.py @@ -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() diff --git a/server/services/ssh_manager.py b/server/services/ssh_manager.py new file mode 100644 index 0000000..2f56035 --- /dev/null +++ b/server/services/ssh_manager.py @@ -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() diff --git a/server/services/telegram_bot.py b/server/services/telegram_bot.py new file mode 100644 index 0000000..c68c35d --- /dev/null +++ b/server/services/telegram_bot.py @@ -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 diff --git a/server/static/css/style.css b/server/static/css/style.css new file mode 100644 index 0000000..d37f90e --- /dev/null +++ b/server/static/css/style.css @@ -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; } +} diff --git a/server/static/js/app.js b/server/static/js/app.js new file mode 100644 index 0000000..194bc07 --- /dev/null +++ b/server/static/js/app.js @@ -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 = '
Нет серверов. Нажмите "+ Добавить сервер"
'; + 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 ` +
+
+
+
${srv.name}
+
${srv.host}:${srv.port || 22}
+
+ + ${isOnline ? 'Online' : 'Offline'} + +
+ ${isOnline ? ` +
+
+ CPU + ${cpu}% +
+
+
+ RAM + ${ram}% +
+
+
+ Disk + ${disk}% +
+
+
+ ` : '
Нет данных
'} +
+ + + +
+
+ `; + }).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 = ` +
+
+ +
${srv.host}:${srv.port || 22}
+
+
+ +
${m.online ? 'Online' : 'Offline'}
+
+ ${m.online ? ` +
+ +
${m.uptime || 'N/A'}
+
+
+ +
${m.load_average || 'N/A'}
+
+
+ +
${(m.ram_used_mb||0).toFixed(0)} / ${(m.ram_total_mb||0).toFixed(0)} MB (${m.ram_percent||0}%)
+
+
+ +
${(m.disk_used_gb||0).toFixed(1)} / ${(m.disk_total_gb||0).toFixed(1)} GB (${m.disk_percent||0}%)
+
+
+ +
↓ ${(m.network_in_mb||0).toFixed(0)} MB / ↑ ${(m.network_out_mb||0).toFixed(0)} MB
+
+ ` : ''} + ${srv.description ? ` +
+ +
${srv.description}
+
+ ` : ''} +
+ `; + + 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 = '
xterm.js не загружен
'; + 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); diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html new file mode 100644 index 0000000..f37261a --- /dev/null +++ b/server/templates/dashboard.html @@ -0,0 +1,157 @@ + + + + + + VPS Monitoring + + + + +
+
+

🖥 VPS Monitoring

+
+
+ + + +
+
+ +
+
+
+ Всего + 0 +
+
+ Online + 0 +
+
+ Offline + 0 +
+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..3930402 --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,56 @@ + + + + + + VPS Monitoring — Вход + + + + + + + diff --git a/server/templates/telegram_app.html b/server/templates/telegram_app.html new file mode 100644 index 0000000..edf5746 --- /dev/null +++ b/server/templates/telegram_app.html @@ -0,0 +1,255 @@ + + + + + + VPS Monitor + + + + +
+

🖥 VPS Monitor

+
Мониторинг серверов
+
+ +
+
+
-
+
Всего
+
+
+
-
+
Online
+
+
+
-
+
Offline
+
+
+ + + + + + + + diff --git a/systemd/vps-monitoring.service b/systemd/vps-monitoring.service new file mode 100644 index 0000000..26af255 --- /dev/null +++ b/systemd/vps-monitoring.service @@ -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