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:
Андрей Бобырев
2026-05-20 05:47:28 +03:00
commit e6e88314d5
23 changed files with 2507 additions and 0 deletions

87
README.md Normal file
View 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
View 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
View 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
View File

0
server/api/__init__.py Normal file
View File

51
server/api/auth_routes.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 = ""

View File

View 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
View 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()

View 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()

View 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
View 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
View 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);

View 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')">&times;</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')">&times;</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()">&times;</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')">&times;</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>

View 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>

View 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>

View 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