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

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>