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