Files
vps_monitoring/server/templates/telegram_app.html
Андрей Бобырев f9f9195e14 fix: Telegram Mini App auth via initData (no web login needed)
- Validate Telegram WebApp initData using HMAC-SHA256
- Dual auth: cookie (web panel) OR X-Telegram-Init-Data header
- Mini App sends initData with every API request
- Retry button on auth failure instead of dead end

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 13:00:47 +03:00

564 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>VPS Monitor</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--tg-theme-bg-color, #0f172a);
color: var(--tg-theme-text-color, #e2e8f0);
min-height: 100vh;
padding: 0 0 24px 0;
-webkit-font-smoothing: antialiased;
}
/* Header */
.app-header {
padding: 20px 16px 16px;
text-align: center;
}
.app-header h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
}
.app-header .stats-row {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.stat-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
background: var(--tg-theme-secondary-bg-color, #1e293b);
}
.stat-pill .dot {
width: 8px; height: 8px;
border-radius: 50%;
}
.stat-pill .dot.green { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
.stat-pill .dot.red { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
.stat-pill .dot.gray { background: #64748b; }
/* Sections */
.section {
padding: 0 16px;
margin-bottom: 8px;
}
.section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--tg-theme-hint-color, #64748b);
padding: 16px 4px 8px;
}
/* Cards */
.card {
display: flex;
align-items: center;
background: var(--tg-theme-secondary-bg-color, #1e293b);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 8px;
cursor: pointer;
transition: transform 0.1s, opacity 0.1s;
position: relative;
overflow: hidden;
}
.card:active {
transform: scale(0.98);
opacity: 0.85;
}
.card-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
margin-right: 14px;
}
.card-icon.green { background: rgba(34, 197, 94, 0.15); }
.card-icon.red { background: rgba(239, 68, 68, 0.15); }
.card-icon.blue { background: rgba(59, 130, 246, 0.15); }
.card-icon.yellow { background: rgba(245, 158, 11, 0.15); }
.card-icon.purple { background: rgba(168, 85, 247, 0.15); }
.card-icon.cyan { background: rgba(34, 211, 238, 0.15); }
.card-body { flex: 1; min-width: 0; }
.card-title {
font-size: 15px;
font-weight: 600;
line-height: 1.3;
}
.card-sub {
font-size: 12px;
color: var(--tg-theme-hint-color, #64748b);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-badge {
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
margin-right: 10px;
flex-shrink: 0;
}
.card-badge.online { background: rgba(34,197,94,0.15); color: #22c55e; }
.card-badge.offline { background: rgba(239,68,68,0.15); color: #ef4444; }
.card-badge.warn { background: rgba(245,158,11,0.15); color: #f59e0b; }
.card-badge.num {
background: rgba(99,102,241,0.15);
color: #818cf8;
font-size: 13px;
font-weight: 700;
padding: 4px 10px;
border-radius: 8px;
min-width: 32px;
text-align: center;
}
.card-chevron {
color: var(--tg-theme-hint-color, #475569);
font-size: 18px;
margin-left: 8px;
flex-shrink: 0;
}
/* Server detail (expandable) */
.server-detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
background: var(--tg-theme-secondary-bg-color, #1e293b);
border-radius: 0 0 14px 14px;
margin-top: -22px;
margin-bottom: 8px;
border: 1px solid rgba(255,255,255,0.06);
border-top: none;
}
.server-detail.open {
max-height: 400px;
}
.server-detail-inner {
padding: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.detail-cell {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px;
}
.detail-cell .label {
font-size: 11px;
color: var(--tg-theme-hint-color, #64748b);
}
.detail-cell .val {
font-size: 16px;
font-weight: 700;
margin-top: 2px;
}
.detail-cell .val.ok { color: #22c55e; }
.detail-cell .val.warn { color: #f59e0b; }
.detail-cell .val.crit { color: #ef4444; }
.detail-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.detail-btn {
padding: 10px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: opacity 0.1s;
color: #fff;
}
.detail-btn:active { opacity: 0.7; }
.detail-btn.primary { background: #3b82f6; }
.detail-btn.danger { background: #ef4444; }
/* Toast */
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--tg-theme-secondary-bg-color, #1e293b);
border: 1px solid rgba(255,255,255,0.1);
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
opacity: 0;
transition: all 0.3s;
z-index: 100;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Loading */
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
flex-direction: column;
gap: 12px;
}
.spinner {
width: 32px; height: 32px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="app">
<div class="loading-screen" id="loadingScreen">
<div class="spinner"></div>
<span style="opacity:0.5;font-size:13px">Загрузка...</span>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const tg = window.Telegram?.WebApp;
if (tg) { tg.ready(); tg.expand(); }
let data = null;
let openServerId = null;
// Toast
function showToast(text) {
const el = document.getElementById('toast');
el.textContent = text;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2500);
}
// Telegram initData for auth
const initData = tg?.initData || '';
// Fetch with auth (cookie or Telegram initData)
async function api(url, opts = {}) {
const headers = opts.headers || {};
if (initData) {
headers['X-Telegram-Init-Data'] = initData;
}
const resp = await fetch(url, {
credentials: 'include',
...opts,
headers
});
if (!resp.ok) throw new Error(resp.status);
return resp.json();
}
// Load all data
async function loadData() {
try {
data = await api('/api/overview');
render();
} catch (e) {
document.getElementById('app').innerHTML = `
<div class="loading-screen">
<span style="font-size:32px">🔒</span>
<span style="opacity:0.5;font-size:13px">Ошибка загрузки. Попробуйте /start</span>
<button onclick="loadData()" style="margin-top:12px;padding:10px 24px;border:none;border-radius:8px;background:#6366f1;color:#fff;font-size:14px;cursor:pointer">🔄 Повторить</button>
</div>`;
}
}
function render() {
if (!data) return;
const app = document.getElementById('app');
let html = '';
// Header
html += `
<div class="app-header">
<h1>🖥 VPS Monitor</h1>
<div class="stats-row">
<div class="stat-pill">
<span class="dot green"></span>
${data.online} online
</div>
<div class="stat-pill">
<span class="dot ${data.offline > 0 ? 'red' : 'gray'}"></span>
${data.offline} offline
</div>
<div class="stat-pill">
<span class="dot gray"></span>
${data.total} всего
</div>
</div>
</div>`;
// Servers section
html += `<div class="section">
<div class="section-title">СЕРВЕРЫ</div>`;
data.servers.forEach((srv, idx) => {
const cpuClass = srv.cpu > 90 ? 'crit' : srv.cpu > 70 ? 'warn' : 'ok';
const ramClass = srv.ram > 90 ? 'crit' : srv.ram > 70 ? 'warn' : 'ok';
const diskClass = srv.disk > 90 ? 'crit' : srv.disk > 70 ? 'warn' : 'ok';
const isOpen = openServerId === srv.id;
html += `
<div class="card" onclick="toggleServer('${srv.id}')" style="${isOpen ? 'border-radius:14px 14px 0 0;margin-bottom:0' : ''}">
<div class="card-icon ${srv.online ? 'green' : 'red'}">
${srv.online ? '🟢' : '🔴'}
</div>
<div class="card-body">
<div class="card-title">${srv.name}</div>
<div class="card-sub">${srv.host}${srv.online ? ' • CPU ' + srv.cpu + '% • RAM ' + srv.ram + '%' : ' • Недоступен'}</div>
</div>
<span class="card-badge ${srv.online ? 'online' : 'offline'}">${srv.online ? 'ON' : 'OFF'}</span>
<span class="card-chevron">${isOpen ? '▾' : ''}</span>
</div>
<div class="server-detail ${isOpen ? 'open' : ''}" id="detail-${srv.id}">
<div class="server-detail-inner">
<div class="detail-grid">
<div class="detail-cell">
<div class="label">CPU</div>
<div class="val ${cpuClass}">${srv.cpu}%</div>
</div>
<div class="detail-cell">
<div class="label">RAM</div>
<div class="val ${ramClass}">${srv.ram}%</div>
</div>
<div class="detail-cell">
<div class="label">Disk</div>
<div class="val ${diskClass}">${srv.disk}%</div>
</div>
<div class="detail-cell">
<div class="label">Load</div>
<div class="val">${srv.load || '—'}</div>
</div>
</div>
<div style="margin-top:8px;font-size:12px;color:var(--tg-theme-hint-color,#64748b)">
${srv.uptime || 'Uptime: N/A'}
</div>
<div class="detail-actions">
<button class="detail-btn primary" onclick="event.stopPropagation();rebootServer('${srv.id}','${srv.name}')">🔄 Reboot</button>
<button class="detail-btn danger" onclick="event.stopPropagation();deleteServer('${srv.id}','${srv.name}')">🗑 Удалить</button>
</div>
</div>
</div>`;
});
if (!data.servers.length) {
html += `
<div class="card" style="justify-content:center;cursor:default">
<div class="card-body" style="text-align:center">
<div class="card-title" style="opacity:0.5">Нет серверов</div>
<div class="card-sub">Добавьте в веб-панели</div>
</div>
</div>`;
}
html += `</div>`;
// Actions section
html += `
<div class="section">
<div class="section-title">ДЕЙСТВИЯ</div>
<div class="card" onclick="refreshAll()">
<div class="card-icon blue">🔄</div>
<div class="card-body">
<div class="card-title">Обновить данные</div>
<div class="card-sub">Принудительный опрос серверов</div>
</div>
<span class="card-chevron"></span>
</div>
<div class="card" onclick="checkAll()">
<div class="card-icon green">✅</div>
<div class="card-body">
<div class="card-title">Проверка серверов</div>
<div class="card-sub">${data.online}/${data.total} доступны</div>
</div>
<span class="card-badge ${data.offline > 0 ? 'offline' : 'online'}">${data.offline > 0 ? data.offline + ' DOWN' : 'ALL OK'}</span>
<span class="card-chevron"></span>
</div>
</div>`;
// Notifications section
html += `
<div class="section">
<div class="section-title">УВЕДОМЛЕНИЯ</div>
${data.muted ? `
<div class="card" onclick="unmute()">
<div class="card-icon yellow">🔔</div>
<div class="card-body">
<div class="card-title">Снять mute</div>
<div class="card-sub">Уведомления приостановлены</div>
</div>
<span class="card-badge warn">MUTED</span>
<span class="card-chevron"></span>
</div>
` : `
<div class="card" onclick="mute(2)">
<div class="card-icon red">🔕</div>
<div class="card-body">
<div class="card-title">Mute 2 часа</div>
<div class="card-sub">CRIT в Telegram остаются</div>
</div>
<span class="card-chevron"></span>
</div>
<div class="card" onclick="mute(8)">
<div class="card-icon red">🔕</div>
<div class="card-body">
<div class="card-title">Mute 8 часов</div>
<div class="card-sub">Ночной режим</div>
</div>
<span class="card-chevron"></span>
</div>
`}
</div>`;
// System section
html += `
<div class="section">
<div class="section-title">СИСТЕМА</div>
<div class="card" onclick="openPanel()">
<div class="card-icon cyan">🌐</div>
<div class="card-body">
<div class="card-title">Полная веб-панель</div>
<div class="card-sub">Дашборд в браузере</div>
</div>
<span class="card-chevron"></span>
</div>
<div class="card" style="cursor:default">
<div class="card-icon purple">🔐</div>
<div class="card-body">
<div class="card-title">Сессия</div>
<div class="card-sub">Сессия активна</div>
</div>
<span class="card-badge online">OK</span>
<span class="card-chevron"></span>
</div>
<div class="card" style="cursor:default">
<div class="card-icon blue">📊</div>
<div class="card-body">
<div class="card-title">Мониторинг</div>
<div class="card-sub">Интервал: 60 сек • Пороги: 90%</div>
</div>
<span class="card-badge online">ACTIVE</span>
<span class="card-chevron"></span>
</div>
</div>`;
// Footer
html += `
<div style="text-align:center;padding:20px 16px 8px;font-size:12px;opacity:0.4">
автор:&ensp;<a href="https://github.com/andrey271192" style="color:inherit" target="_blank">GitHub</a>&ensp;·&ensp;<a href="https://boosty.to/iot_andrey" style="color:inherit" target="_blank">Boosty</a>&ensp;·&ensp;<a href="https://t.me/Iot_andrey" style="color:inherit" target="_blank">Поддержка</a>&ensp;·&ensp;<a href="https://t.me/Iot_andrey" style="color:inherit" target="_blank">@Iot_andrey</a>
</div>`;
app.innerHTML = html;
}
// Actions
function toggleServer(id) {
openServerId = openServerId === id ? null : id;
render();
}
async function rebootServer(id, name) {
if (!confirm(`Перезагрузить ${name}?`)) return;
await api(`/api/servers/${id}/reboot`, {method: 'POST'});
showToast('🔄 Reboot отправлен');
}
async function deleteServer(id, name) {
if (!confirm(`Удалить ${name}?`)) return;
await api(`/api/servers/${id}`, {method: 'DELETE'});
showToast('🗑 Сервер удалён');
loadData();
}
async function refreshAll() {
showToast('🔄 Обновление...');
await loadData();
showToast('✅ Данные обновлены');
}
function checkAll() {
if (data.offline > 0) {
const names = data.servers.filter(s => !s.online).map(s => s.name).join(', ');
if (tg) tg.showAlert(`⚠️ Offline: ${names}`);
else alert(`Offline: ${names}`);
} else {
showToast('✅ Все серверы доступны');
}
}
async function mute(hours) {
await api('/api/mute', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hours})
});
showToast(`🔕 Mute ${hours}ч`);
loadData();
}
async function unmute() {
await api('/api/unmute', {method: 'POST'});
showToast('🔔 Уведомления включены');
loadData();
}
function openPanel() {
const url = `${location.protocol}//${location.host}/`;
if (tg) tg.openLink(url);
else window.open(url, '_blank');
}
// Init
loadData();
setInterval(loadData, 30000);
</script>
</body>
</html>