mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
feat: add Synology NAS, Home Assistant monitoring, multi-channel notifications
New tabs: - Synology NAS: CPU, RAM, temp, volumes, VMs, Docker containers - Home Assistant: sensors, automations, persons, climate, battery, doors New features: - Personalized PC agent download with custom name - Notification settings: per-category Telegram/Email/WhatsApp toggles - Email via SMTP, WhatsApp via CallMeBot API - Test notification buttons for each channel - Unified notifier replaces direct Telegram calls in alerter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -633,6 +633,75 @@ main {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* HA Summary */
|
||||
.ha-summary {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ha-stat {
|
||||
padding: 2px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Notification Matrix */
|
||||
.notify-channels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.notify-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notify-table th,
|
||||
.notify-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notify-table th {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.notify-table td:first-child,
|
||||
.notify-table th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notify-table input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Small button */
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
@@ -660,4 +729,7 @@ main {
|
||||
.stats-bar { flex-direction: column; gap: 8px; }
|
||||
header { padding: 12px 16px; }
|
||||
.header-right { gap: 4px; }
|
||||
.tabs { flex-wrap: wrap; }
|
||||
.tab { flex: 0 1 auto; padding: 8px 12px; font-size: 12px; }
|
||||
.notify-channels { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -197,9 +197,10 @@ function showInstructions() {
|
||||
<li>POST /api/servers — add server</li>
|
||||
<li>DELETE /api/servers/{id} — remove</li>
|
||||
<li>POST /api/servers/{id}/reboot — reboot</li>
|
||||
<li>POST /api/servers/{id}/exec — run command</li>
|
||||
<li>GET /api/settings — get settings</li>
|
||||
<li>PUT /api/settings — update settings</li>
|
||||
<li>GET /api/synology/list — Synology NAS</li>
|
||||
<li>GET /api/ha/list — Home Assistant</li>
|
||||
<li>GET /api/pc/list — PC agents</li>
|
||||
<li>GET /api/notifications/settings</li>
|
||||
<li>WS /ws/ssh/{id} — SSH terminal</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -224,18 +225,13 @@ function openSSH(id, name) {
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#e8e8ff',
|
||||
cursor: '#6366f1',
|
||||
},
|
||||
theme: { background: '#000000', foreground: '#e8e8ff', cursor: '#6366f1' },
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
});
|
||||
|
||||
term.open(container);
|
||||
|
||||
// Fit addon
|
||||
if (typeof FitAddon !== 'undefined') {
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
@@ -249,38 +245,16 @@ function openSSH(id, name) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
if (currentWs) { currentWs.close(); currentWs = null; }
|
||||
if (currentTerminal) { currentTerminal.dispose(); currentTerminal = null; }
|
||||
closeModal('sshModal');
|
||||
}
|
||||
|
||||
@@ -290,9 +264,7 @@ async function rebootServer(id) {
|
||||
try {
|
||||
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
|
||||
alert(t('rebootSent'));
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteServer(id) {
|
||||
@@ -300,9 +272,7 @@ async function deleteServer(id) {
|
||||
try {
|
||||
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
|
||||
loadServers();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
// Add server
|
||||
@@ -337,9 +307,7 @@ document.getElementById('addServerForm')?.addEventListener('submit', async (e) =
|
||||
const err = await resp.json();
|
||||
alert(err.detail || 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
|
||||
// Settings
|
||||
@@ -383,9 +351,7 @@ document.getElementById('settingsForm')?.addEventListener('submit', async (e) =>
|
||||
});
|
||||
closeModal('settingsModal');
|
||||
alert(t('settingsSaved'));
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
|
||||
// Utils
|
||||
@@ -394,7 +360,10 @@ function closeModal(id) {
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadServers();
|
||||
if (activeTab === 'servers') loadServers();
|
||||
else if (activeTab === 'pc') loadPCs();
|
||||
else if (activeTab === 'synology') loadSynology();
|
||||
else if (activeTab === 'ha') loadHA();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -402,31 +371,43 @@ async function logout() {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Brief visual feedback
|
||||
const el = event.target;
|
||||
const orig = el.style.color;
|
||||
el.style.color = 'var(--accent)';
|
||||
setTimeout(() => el.style.color = orig, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Click outside modal to close
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
if (e.target.id === 'sshModal') {
|
||||
closeSSH();
|
||||
} else {
|
||||
e.target.style.display = 'none';
|
||||
}
|
||||
if (e.target.id === 'sshModal') closeSSH();
|
||||
else e.target.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Tabs
|
||||
// ===================== TABS =====================
|
||||
let activeTab = 'servers';
|
||||
const allTabs = ['servers', 'pc', 'synology', 'ha'];
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
document.getElementById('tab-servers').classList.toggle('active', tab === 'servers');
|
||||
document.getElementById('tab-pc').classList.toggle('active', tab === 'pc');
|
||||
document.getElementById('panel-servers').style.display = tab === 'servers' ? '' : 'none';
|
||||
document.getElementById('panel-pc').style.display = tab === 'pc' ? '' : 'none';
|
||||
allTabs.forEach(t => {
|
||||
const tabBtn = document.getElementById('tab-' + t);
|
||||
const panel = document.getElementById('panel-' + t);
|
||||
if (tabBtn) tabBtn.classList.toggle('active', t === tab);
|
||||
if (panel) panel.style.display = t === tab ? '' : 'none';
|
||||
});
|
||||
|
||||
if (tab === 'pc') loadPCs();
|
||||
else if (tab === 'synology') loadSynology();
|
||||
else if (tab === 'ha') loadHA();
|
||||
}
|
||||
|
||||
// PC monitoring
|
||||
// ===================== PC MONITORING =====================
|
||||
let pcAgents = [];
|
||||
|
||||
async function loadPCs() {
|
||||
@@ -435,16 +416,14 @@ async function loadPCs() {
|
||||
if (resp.status === 401) return;
|
||||
pcAgents = await resp.json();
|
||||
renderPCs();
|
||||
} catch (e) {
|
||||
console.error('PC load error:', e);
|
||||
}
|
||||
} catch (e) { console.error('PC load error:', e); }
|
||||
}
|
||||
|
||||
function renderPCs() {
|
||||
const grid = document.getElementById('pc-grid');
|
||||
|
||||
if (!pcAgents.length) {
|
||||
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Установите агент.</div>';
|
||||
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Нажмите "📥 Скачать агент".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -522,7 +501,556 @@ function showPCSetup() {
|
||||
document.getElementById('pcSetupModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Initial load
|
||||
function showPCDownload() {
|
||||
document.getElementById('pc-agent-name').value = '';
|
||||
document.getElementById('pc-generated-cmd').style.display = 'none';
|
||||
document.getElementById('pcDownloadModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function generatePCCommand() {
|
||||
const name = document.getElementById('pc-agent-name').value.trim() || 'MyPC';
|
||||
try {
|
||||
const resp = await fetch('/api/notifications/generate-pc-agent', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({agent_name: name}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.command) {
|
||||
document.getElementById('pc-gen-code').textContent = data.command;
|
||||
document.getElementById('pc-generated-cmd').style.display = 'block';
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
// ===================== SYNOLOGY =====================
|
||||
let synologyDevices = [];
|
||||
|
||||
async function loadSynology() {
|
||||
try {
|
||||
const resp = await fetch('/api/synology/list', {credentials: 'include'});
|
||||
if (resp.status === 401) return;
|
||||
synologyDevices = await resp.json();
|
||||
renderSynology();
|
||||
} catch (e) { console.error('Synology load error:', e); }
|
||||
}
|
||||
|
||||
function renderSynology() {
|
||||
const grid = document.getElementById('synology-grid');
|
||||
|
||||
if (!synologyDevices.length) {
|
||||
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет Synology NAS. Нажмите "+ Добавить NAS".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = synologyDevices.map(dev => {
|
||||
const m = dev.metrics || {};
|
||||
const isOnline = m.online;
|
||||
const cpu = m.cpu_percent || 0;
|
||||
const ram = m.ram_percent || 0;
|
||||
const temp = m.temperature || 0;
|
||||
|
||||
const cpuClass = cpu > 90 ? 'crit' : cpu > 70 ? 'warn' : '';
|
||||
const ramClass = ram > 90 ? 'crit' : ram > 70 ? 'warn' : '';
|
||||
const tempClass = temp > 60 ? 'crit' : temp > 50 ? 'warn' : '';
|
||||
|
||||
const vmsCount = (m.vms || []).length;
|
||||
const dockerCount = (m.docker || []).length;
|
||||
const volsHtml = (m.volumes || []).map(v => {
|
||||
const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : '';
|
||||
return `<div style="font-size:11px;margin-top:4px">
|
||||
${v.name}: ${v.used_gb}/${v.total_gb} GB
|
||||
<div class="progress-bar"><div class="fill ${vc || 'ok'}" style="width:${v.percent}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showSynologyDetail('${dev.name}')">
|
||||
<div class="server-card-header">
|
||||
<div>
|
||||
<div class="name">📦 ${dev.name}</div>
|
||||
<div class="host">${dev.host}:${dev.port || 5000} ${m.model ? '• ' + m.model : ''}</div>
|
||||
</div>
|
||||
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||
${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">🌡️</span>
|
||||
<span class="value ${tempClass}">${temp}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
${m.dsm_version ? `<div style="font-size:11px;color:var(--text-secondary);margin-bottom:4px">${m.dsm_version} • ${m.uptime || ''}</div>` : ''}
|
||||
${vmsCount ? `<div style="font-size:11px;color:var(--accent)">🖥 ${vmsCount} VM</div>` : ''}
|
||||
${dockerCount ? `<div style="font-size:11px;color:var(--accent)">🐳 ${dockerCount} контейнеров</div>` : ''}
|
||||
${volsHtml}
|
||||
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных — нажмите 🔄</div>'}
|
||||
<div class="server-card-actions">
|
||||
<button onclick="event.stopPropagation(); refreshSynology('${dev.name}')">🔄 Обновить</button>
|
||||
<button class="danger" onclick="event.stopPropagation(); deleteSynology('${dev.name}')">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showSynologyDetail(name) {
|
||||
const dev = synologyDevices.find(d => d.name === name);
|
||||
if (!dev || !dev.metrics) return;
|
||||
|
||||
const m = dev.metrics;
|
||||
const modal = document.getElementById('synDetailModal');
|
||||
document.getElementById('syn-detail-title').textContent = `📦 ${name}`;
|
||||
|
||||
let html = `<div style="display:grid;gap:12px">`;
|
||||
|
||||
// System
|
||||
html += `<div class="instr-card"><h4>💻 Система</h4>
|
||||
<p>Модель: ${m.model || 'N/A'}<br>${m.dsm_version || ''}<br>Uptime: ${m.uptime || 'N/A'}<br>
|
||||
Температура: ${m.temperature || 0}°C<br>CPU: ${m.cpu_percent || 0}% | RAM: ${m.ram_percent || 0}% (${m.ram_used_mb || 0}/${m.ram_total_mb || 0} MB)</p></div>`;
|
||||
|
||||
// Volumes
|
||||
if (m.volumes?.length) {
|
||||
html += `<div class="instr-card"><h4>💾 Тома</h4>`;
|
||||
m.volumes.forEach(v => {
|
||||
const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : 'ok';
|
||||
html += `<div style="margin-bottom:8px"><b>${v.name}</b> — ${v.used_gb}/${v.total_gb} GB (${v.percent}%) ${v.status ? '• ' + v.status : ''}
|
||||
<div class="progress-bar"><div class="fill ${vc}" style="width:${v.percent}%"></div></div></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Disks
|
||||
if (m.disks?.length) {
|
||||
html += `<div class="instr-card"><h4>🔩 Диски</h4>`;
|
||||
m.disks.forEach(d => {
|
||||
html += `<div style="margin-bottom:4px">${d.name} — ${d.model || ''} (${d.size_gb} GB) 🌡${d.temp}°C ${d.smart_status ? '• SMART: ' + d.smart_status : ''} ${d.status || ''}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// VMs
|
||||
if (m.vms?.length) {
|
||||
html += `<div class="instr-card"><h4>🖥 Виртуальные машины</h4>`;
|
||||
m.vms.forEach(vm => {
|
||||
const st = vm.status === 'running' ? '🟢' : '🔴';
|
||||
html += `<div style="margin-bottom:4px">${st} ${vm.name} — ${vm.vcpu} vCPU, ${vm.ram_mb} MB RAM ${vm.autorun ? '• AutoStart' : ''}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Docker
|
||||
if (m.docker?.length) {
|
||||
html += `<div class="instr-card"><h4>🐳 Docker контейнеры</h4>`;
|
||||
m.docker.forEach(c => {
|
||||
const st = c.state === 'running' ? '🟢' : '🔴';
|
||||
html += `<div style="margin-bottom:4px">${st} ${c.name} — ${c.image || ''}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
document.getElementById('syn-detail-content').innerHTML = html;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showAddSynology() {
|
||||
document.getElementById('addSynologyModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
document.getElementById('addSynologyForm')?.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) || 5000,
|
||||
https: form.querySelector('[name=https]').checked,
|
||||
username: form.username.value,
|
||||
password: form.password.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/synology/add', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.ok) {
|
||||
closeModal('addSynologyModal');
|
||||
form.reset();
|
||||
loadSynology();
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
|
||||
async function refreshSynology(name) {
|
||||
try {
|
||||
await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
|
||||
loadSynology();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function refreshAllSynology() {
|
||||
try {
|
||||
await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'});
|
||||
loadSynology();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteSynology(name) {
|
||||
if (!confirm(`Удалить Synology "${name}"?`)) return;
|
||||
await fetch(`/api/synology/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'});
|
||||
loadSynology();
|
||||
}
|
||||
|
||||
// ===================== HOME ASSISTANT =====================
|
||||
let haInstances = [];
|
||||
|
||||
async function loadHA() {
|
||||
try {
|
||||
const resp = await fetch('/api/ha/list', {credentials: 'include'});
|
||||
if (resp.status === 401) return;
|
||||
haInstances = await resp.json();
|
||||
renderHA();
|
||||
} catch (e) { console.error('HA load error:', e); }
|
||||
}
|
||||
|
||||
function renderHA() {
|
||||
const grid = document.getElementById('ha-grid');
|
||||
|
||||
if (!haInstances.length) {
|
||||
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет Home Assistant. Нажмите "+ Добавить HA".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = haInstances.map(inst => {
|
||||
const m = inst.metrics || {};
|
||||
const isOnline = m.online;
|
||||
const temps = (m.sensors?.temperature || []).filter(s => s.value !== null).slice(0, 4);
|
||||
const humidity = (m.sensors?.humidity || []).filter(s => s.value !== null).slice(0, 4);
|
||||
const doors = m.sensors?.door || [];
|
||||
const motion = m.sensors?.motion || [];
|
||||
const battery = (m.sensors?.battery || []).filter(s => s.value !== null && s.value < 20);
|
||||
const problems = (m.problem_entities || []).length;
|
||||
const updates = (m.updates_available || []).length;
|
||||
const persons = m.persons || [];
|
||||
|
||||
return `
|
||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showHADetail('${inst.name}')">
|
||||
<div class="server-card-header">
|
||||
<div>
|
||||
<div class="name">🏠 ${inst.name}</div>
|
||||
<div class="host">${m.location_name || inst.url || ''} ${m.version ? '• v' + m.version : ''}</div>
|
||||
</div>
|
||||
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||
${isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
${isOnline ? `
|
||||
<div class="ha-summary">
|
||||
<div class="ha-stat">📡 ${m.entities_count || 0} сущностей</div>
|
||||
${persons.length ? `<div class="ha-stat">👤 ${persons.map(p => `${p.name}: ${p.state === 'home' ? '🏠' : '🚗'}`).join(', ')}</div>` : ''}
|
||||
${temps.length ? `<div class="ha-stat">🌡 ${temps.map(s => s.name.substring(0, 15) + ': ' + s.value + s.unit).join(' | ')}</div>` : ''}
|
||||
${humidity.length ? `<div class="ha-stat">💧 ${humidity.map(s => s.name.substring(0, 15) + ': ' + s.value + '%').join(' | ')}</div>` : ''}
|
||||
${doors.length ? `<div class="ha-stat">🚪 ${doors.filter(d => d.state === 'on').length ? '<span style="color:var(--warning)">' + doors.filter(d => d.state === 'on').length + ' открыто</span>' : 'все закрыты ✅'}</div>` : ''}
|
||||
${motion.length ? `<div class="ha-stat">🏃 ${motion.filter(m => m.state === 'on').length ? '<span style="color:var(--warning)">движение!</span>' : 'нет движения'}</div>` : ''}
|
||||
${battery.length ? `<div class="ha-stat" style="color:var(--danger)">🔋 ${battery.length} устройств с низким зарядом</div>` : ''}
|
||||
${problems ? `<div class="ha-stat" style="color:var(--danger)">⚠️ ${problems} проблемных сущностей</div>` : ''}
|
||||
${updates ? `<div class="ha-stat" style="color:var(--warning)">📦 ${updates} обновлений</div>` : ''}
|
||||
</div>
|
||||
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных — нажмите 🔄</div>'}
|
||||
<div class="server-card-actions">
|
||||
<button onclick="event.stopPropagation(); refreshHA('${inst.name}')">🔄 Обновить</button>
|
||||
<button class="danger" onclick="event.stopPropagation(); deleteHA('${inst.name}')">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showHADetail(name) {
|
||||
const inst = haInstances.find(i => i.name === name);
|
||||
if (!inst || !inst.metrics) return;
|
||||
|
||||
const m = inst.metrics;
|
||||
const modal = document.getElementById('haDetailModal');
|
||||
document.getElementById('ha-detail-title').textContent = `🏠 ${name}`;
|
||||
|
||||
let html = `<div style="display:grid;gap:12px">`;
|
||||
|
||||
// System
|
||||
html += `<div class="instr-card"><h4>🔧 Система</h4>
|
||||
<p>Версия: ${m.version || 'N/A'}<br>Локация: ${m.location_name || 'N/A'}<br>
|
||||
Компоненты: ${m.components_count || 0}<br>Сущности: ${m.entities_count || 0}</p></div>`;
|
||||
|
||||
// Temperatures
|
||||
const temps = m.sensors?.temperature || [];
|
||||
if (temps.length) {
|
||||
html += `<div class="instr-card"><h4>🌡️ Температура</h4>`;
|
||||
temps.forEach(s => {
|
||||
if (s.value === null) return;
|
||||
const cls = s.value > 30 ? 'style="color:var(--danger)"' : s.value < 10 ? 'style="color:var(--accent)"' : '';
|
||||
html += `<div ${cls}>${s.name}: <b>${s.value}${s.unit}</b></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Humidity
|
||||
const humid = m.sensors?.humidity || [];
|
||||
if (humid.length) {
|
||||
html += `<div class="instr-card"><h4>💧 Влажность</h4>`;
|
||||
humid.forEach(s => { if (s.value !== null) html += `<div>${s.name}: <b>${s.value}%</b></div>`; });
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Doors/Windows
|
||||
const doors = m.sensors?.door || [];
|
||||
if (doors.length) {
|
||||
html += `<div class="instr-card"><h4>🚪 Двери / Окна</h4>`;
|
||||
doors.forEach(d => {
|
||||
const icon = d.state === 'on' ? '🔓 Открыто' : '🔒 Закрыто';
|
||||
const cls = d.state === 'on' ? 'style="color:var(--warning)"' : '';
|
||||
html += `<div ${cls}>${d.name}: ${icon}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Motion
|
||||
const motion = m.sensors?.motion || [];
|
||||
if (motion.length) {
|
||||
html += `<div class="instr-card"><h4>🏃 Движение</h4>`;
|
||||
motion.forEach(s => {
|
||||
const cls = s.state === 'on' ? 'style="color:var(--warning)"' : '';
|
||||
html += `<div ${cls}>${s.name}: ${s.state === 'on' ? '⚡ Обнаружено' : '—'}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Battery
|
||||
const battery = m.sensors?.battery || [];
|
||||
if (battery.length) {
|
||||
html += `<div class="instr-card"><h4>🔋 Батареи</h4>`;
|
||||
battery.forEach(b => {
|
||||
if (b.value === null) return;
|
||||
const cls = b.value < 20 ? 'crit' : b.value < 50 ? 'warn' : '';
|
||||
html += `<div><span class="value ${cls}">${b.value}%</span> ${b.name}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Climate
|
||||
if (m.climate?.length) {
|
||||
html += `<div class="instr-card"><h4>🌡 Климат</h4>`;
|
||||
m.climate.forEach(c => {
|
||||
html += `<div>${c.name}: ${c.state} (${c.current_temp || '?'}° → ${c.target_temp || '?'}°) ${c.hvac_action}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Persons
|
||||
if (m.persons?.length) {
|
||||
html += `<div class="instr-card"><h4>👤 Люди</h4>`;
|
||||
m.persons.forEach(p => {
|
||||
const icon = p.state === 'home' ? '🏠 Дома' : '🚗 Не дома';
|
||||
html += `<div>${p.name}: ${icon}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Automations
|
||||
if (m.automations?.length) {
|
||||
html += `<div class="instr-card"><h4>⚙️ Автоматизации (${m.automations.length})</h4>`;
|
||||
m.automations.slice(0, 20).forEach(a => {
|
||||
const st = a.state === 'on' ? '🟢' : '🔴';
|
||||
html += `<div style="font-size:12px">${st} ${a.name}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Updates
|
||||
if (m.updates_available?.length) {
|
||||
html += `<div class="instr-card"><h4>📦 Доступные обновления</h4>`;
|
||||
m.updates_available.forEach(u => {
|
||||
html += `<div>${u.name}: ${u.installed} → ${u.latest}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Problems
|
||||
if (m.problem_entities?.length) {
|
||||
html += `<div class="instr-card"><h4>⚠️ Проблемные сущности</h4>`;
|
||||
m.problem_entities.slice(0, 20).forEach(p => {
|
||||
html += `<div style="font-size:12px;color:var(--danger)">${p.entity_id}: ${p.state}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
document.getElementById('ha-detail-content').innerHTML = html;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showAddHA() {
|
||||
document.getElementById('addHAModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
document.getElementById('addHAForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.name.value,
|
||||
url: form.url.value,
|
||||
token: form.token.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/ha/add', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.ok) {
|
||||
closeModal('addHAModal');
|
||||
form.reset();
|
||||
loadHA();
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
|
||||
async function refreshHA(name) {
|
||||
try {
|
||||
await fetch(`/api/ha/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
|
||||
loadHA();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function refreshAllHA() {
|
||||
try {
|
||||
await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'});
|
||||
loadHA();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteHA(name) {
|
||||
if (!confirm(`Удалить Home Assistant "${name}"?`)) return;
|
||||
await fetch(`/api/ha/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'});
|
||||
loadHA();
|
||||
}
|
||||
|
||||
// ===================== NOTIFICATIONS =====================
|
||||
async function showNotificationSettings() {
|
||||
try {
|
||||
const resp = await fetch('/api/notifications/settings', {credentials: 'include'});
|
||||
const data = await resp.json();
|
||||
|
||||
const n = data.notifications || {};
|
||||
// Set checkboxes
|
||||
['servers', 'pc', 'synology', 'ha'].forEach(cat => {
|
||||
const prefs = n[cat] || {};
|
||||
const tgEl = document.getElementById(`n-${cat}-tg`);
|
||||
const emEl = document.getElementById(`n-${cat}-email`);
|
||||
const waEl = document.getElementById(`n-${cat}-wa`);
|
||||
if (tgEl) tgEl.checked = prefs.telegram !== false;
|
||||
if (emEl) emEl.checked = !!prefs.email;
|
||||
if (waEl) waEl.checked = !!prefs.whatsapp;
|
||||
});
|
||||
|
||||
// Email fields
|
||||
document.getElementById('n-smtp-host').value = data.email?.smtp_host || '';
|
||||
document.getElementById('n-smtp-port').value = data.email?.smtp_port || 587;
|
||||
document.getElementById('n-smtp-user').value = data.email?.smtp_user || '';
|
||||
document.getElementById('n-email-to').value = data.email?.email_to || '';
|
||||
|
||||
// WhatsApp
|
||||
document.getElementById('n-wa-phone').value = data.whatsapp?.phone || '';
|
||||
|
||||
// Status indicators
|
||||
document.getElementById('tg-status').innerHTML = data.telegram?.configured
|
||||
? '<span style="color:var(--success)">✅ Настроен</span>'
|
||||
: '<span style="color:var(--danger)">❌ Не настроен (укажите Bot Token и Chat ID в ⚙️)</span>';
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
document.getElementById('notifyModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function saveNotificationSettings() {
|
||||
const notifications = {};
|
||||
['servers', 'pc', 'synology', 'ha'].forEach(cat => {
|
||||
notifications[cat] = {
|
||||
telegram: document.getElementById(`n-${cat}-tg`)?.checked || false,
|
||||
email: document.getElementById(`n-${cat}-email`)?.checked || false,
|
||||
whatsapp: document.getElementById(`n-${cat}-wa`)?.checked || false,
|
||||
};
|
||||
});
|
||||
|
||||
const data = {
|
||||
notifications,
|
||||
smtp_host: document.getElementById('n-smtp-host').value,
|
||||
smtp_port: parseInt(document.getElementById('n-smtp-port').value) || 587,
|
||||
smtp_user: document.getElementById('n-smtp-user').value,
|
||||
smtp_password: document.getElementById('n-smtp-pass').value,
|
||||
email_to: document.getElementById('n-email-to').value,
|
||||
whatsapp_phone: document.getElementById('n-wa-phone').value,
|
||||
whatsapp_apikey: document.getElementById('n-wa-apikey').value,
|
||||
};
|
||||
|
||||
// Don't send empty password
|
||||
if (!data.smtp_password) delete data.smtp_password;
|
||||
if (!data.whatsapp_apikey) delete data.whatsapp_apikey;
|
||||
|
||||
try {
|
||||
await fetch('/api/notifications/settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
closeModal('notifyModal');
|
||||
alert('Настройки уведомлений сохранены');
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function testNotification(channel) {
|
||||
try {
|
||||
// Save first if email/whatsapp have new values
|
||||
if (channel !== 'telegram') {
|
||||
await saveNotificationSettings();
|
||||
// Re-open modal
|
||||
document.getElementById('notifyModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/notifications/test/${channel}`, {
|
||||
method: 'POST', credentials: 'include'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'ok') {
|
||||
alert(`✅ Тестовое уведомление отправлено через ${channel}`);
|
||||
} else {
|
||||
alert(`❌ Ошибка отправки через ${channel}. Проверьте настройки.`);
|
||||
}
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
// ===================== INIT =====================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyTheme(currentTheme);
|
||||
renderPage();
|
||||
@@ -531,5 +1059,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
setInterval(() => {
|
||||
if (activeTab === 'servers') loadServers();
|
||||
else loadPCs();
|
||||
else if (activeTab === 'pc') loadPCs();
|
||||
else if (activeTab === 'synology') loadSynology();
|
||||
else if (activeTab === 'ha') loadHA();
|
||||
}, 30000);
|
||||
|
||||
@@ -73,6 +73,8 @@ const translations = {
|
||||
instr_api: "API endpoints",
|
||||
instr_thresholds: "Пороги алертов",
|
||||
instr_thresholds_text: "Настройки → Мониторинг. По умолчанию: CPU 90%, RAM 90%, Disk 90%",
|
||||
tabServers: "Серверы",
|
||||
tabPC: "ПК",
|
||||
},
|
||||
en: {
|
||||
title: "VPS Monitoring",
|
||||
@@ -148,6 +150,8 @@ const translations = {
|
||||
instr_api: "API endpoints",
|
||||
instr_thresholds: "Alert thresholds",
|
||||
instr_thresholds_text: "Settings → Monitoring. Default: CPU 90%, RAM 90%, Disk 90%",
|
||||
tabServers: "Servers",
|
||||
tabPC: "PC",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user