let servers = []; let currentTerminal = null; let currentWs = null; let mutedDevices = new Set(); async function loadMutedDevices() { try { const resp = await fetch('/api/notifications/muted-devices', {credentials: 'include'}); const data = await resp.json(); mutedDevices = new Set(data.muted || []); } catch (e) { console.error('Muted devices load error:', e); } } async function toggleMuteDevice(btn) { const category = btn.dataset.cat; const name = btn.dataset.name; try { const resp = await fetch('/api/notifications/mute-device', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({category, name}), }); const data = await resp.json(); const key = `${category}:${name}`; if (data.muted) { mutedDevices.add(key); } else { mutedDevices.delete(key); } btn.textContent = data.muted ? '🔕' : '🔔'; btn.className = 'mute-bell' + (data.muted ? ' muted' : ''); btn.title = data.muted ? 'Уведомления выключены' : 'Уведомления включены'; } catch (e) { console.error('Mute toggle error:', e); } } // Delegate bell clicks from document level document.addEventListener('click', function(e) { const bell = e.target.closest('.mute-bell'); if (bell) { e.stopPropagation(); e.preventDefault(); toggleMuteDevice(bell); } }); function bellHtml(category, name) { const key = `${category}:${name}`; const isMuted = mutedDevices.has(key); const esc = name.replace(/"/g, '"'); return ``; } // i18n render function renderPage() { document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); document.getElementById('langBtn').textContent = currentLang.toUpperCase(); document.getElementById('addServerBtn').textContent = t('addServer'); renderServers(); } // 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 = `
${t('noServers')}
`; 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 `
${bellHtml('servers', srv.name)}
${srv.name}
${srv.host}:${srv.port || 22}
${isOnline ? 'Online' : 'Offline'}
${isOnline ? `
CPU ${cpu}%
RAM ${ram}%
Disk ${disk}%
` : `
${t('noData')}
`}
`; }).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 = `
${srv.host}:${srv.port || 22}
${m.online ? 'Online' : 'Offline'}
${m.online ? `
${m.uptime || 'N/A'}
${m.load_average || 'N/A'}
${(m.ram_used_mb||0).toFixed(0)} / ${(m.ram_total_mb||0).toFixed(0)} MB (${m.ram_percent||0}%)
${(m.disk_used_gb||0).toFixed(1)} / ${(m.disk_total_gb||0).toFixed(1)} GB (${m.disk_percent||0}%)
↓ ${(m.network_in_mb||0).toFixed(0)} MB / ↑ ${(m.network_out_mb||0).toFixed(0)} MB
` : ''} ${srv.description ? `
${srv.description}
` : ''}
`; modal.style.display = 'flex'; } // Instructions function showInstructions() { const content = document.getElementById('instructions-content'); content.innerHTML = `

📥 ${t('instr_install')}

${t('instr_add_server_text').includes('Click') ? 'One command to install on any Ubuntu/Debian server:' : 'Одна команда для установки на Ubuntu/Debian:'}

${t('instr_install_text')}

➕ ${t('instr_add_server')}

${t('instr_add_server_text')}

💻 ${t('instr_ssh')}

${t('instr_ssh_text')}

🤖 ${t('instr_telegram')}

${t('instr_telegram_text')}

🔄 ${t('instr_reboot')}

${t('instr_reboot_text')}

⚠️ ${t('instr_thresholds')}

${t('instr_thresholds_text')}

🔧 ${t('instr_commands')}

🔌 ${t('instr_api')}

`; document.getElementById('instructionsModal').style.display = 'flex'; } // SSH Terminal function openSSH(id, name) { const modal = document.getElementById('sshModal'); document.getElementById('ssh-title').textContent = `${t('sshTerminal')} — ${name}`; modal.style.display = 'flex'; const container = document.getElementById('terminal-container'); container.innerHTML = ''; if (typeof Terminal === 'undefined') { container.innerHTML = '
xterm.js not loaded
'; 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); if (typeof FitAddon !== 'undefined') { const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); setTimeout(() => fitAddon.fit(), 100); window.addEventListener('resize', () => fitAddon.fit()); } 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(t('rebootConfirm'))) return; try { await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'}); alert(t('rebootSent')); } catch (e) { alert('Error: ' + e.message); } } async function deleteServer(id) { if (!confirm(t('deleteConfirm'))) return; try { await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'}); loadServers(); } catch (e) { alert('Error: ' + 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 || 'Error'); } } catch (e) { alert('Error: ' + 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(t('settingsSaved')); } catch (e) { alert('Error: ' + e.message); } }); // Utils function closeModal(id) { document.getElementById(id).style.display = 'none'; } function refreshAll() { if (activeTab === 'servers') loadServers(); else if (activeTab === 'pc') loadPCs(); else if (activeTab === 'synology') loadSynology(); else if (activeTab === 'ha') loadHA(); } async function logout() { await fetch('/api/logout', {method: 'POST', credentials: 'include'}); 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'; } }); // ===================== TABS ===================== let activeTab = 'servers'; const allTabs = ['servers', 'pc', 'synology', 'ha', 'keenetic']; function switchTab(tab) { activeTab = tab; 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(); else if (tab === 'keenetic') loadKeenetic(); } // ===================== PC MONITORING ===================== let pcAgents = []; async function loadPCs() { try { const resp = await fetch('/api/pc/list', {credentials: 'include'}); if (resp.status === 401) return; pcAgents = await resp.json(); renderPCs(); } catch (e) { console.error('PC load error:', e); } } function renderPCs() { const grid = document.getElementById('pc-grid'); if (!pcAgents.length) { grid.innerHTML = '
Нет подключённых ПК. Нажмите "📥 Скачать агент".
'; return; } grid.innerHTML = pcAgents.map(pc => { const m = pc.metrics || {}; const isOnline = pc.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 disksHtml = (m.disks || []).map(d => `
${d.drive} ${d.used_gb}/${d.total_gb} GB (${d.percent}%)
` ).join(''); const procsHtml = (m.top_processes || []).slice(0, 3).map(p => `${p.name} ${p.ram_mb}MB` ).join(' '); return `
${bellHtml('pc', pc.agent_name)}
💻 ${pc.agent_name}
${m.hostname || ''} • ${pc.ip || ''}
${isOnline ? 'Online' : 'Offline'}
${isOnline ? `
CPU ${cpu}%
RAM ${ram}%
Disk ${disk}%
${m.uptime || ''} ${m.os ? '• ' + m.os.substring(0, 30) : ''}
${m.gpu_name ? `
GPU: ${m.gpu_name}
` : ''} ${disksHtml} ${procsHtml ? `
${procsHtml}
` : ''} ` : '
Нет данных
'}
`; }).join(''); } async function deletePC(name) { if (!confirm(`Удалить ПК "${name}" из мониторинга?`)) return; await fetch(`/api/pc/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'}); loadPCs(); } function showPCSetup() { document.getElementById('pcSetupModal').style.display = 'flex'; } 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 = '
Нет Synology NAS. Нажмите "+ Добавить NAS".
'; 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 `
${v.name}: ${v.used_gb}/${v.total_gb} GB
`; }).join(''); return `
${bellHtml('synology', dev.name)}
📦 ${dev.name}
${dev.tunnel?.enabled ? '🔗 tunnel' : dev.host + ':' + (dev.port || 5000)} ${m.model ? '• ' + m.model : ''}
${isOnline ? 'Online' : 'Offline'}
${isOnline ? `
CPU ${cpu}%
RAM ${ram}%
🌡️ ${temp}°C
${m.dsm_version ? `
${m.dsm_version} • ${m.uptime || ''}
` : ''} ${vmsCount ? `
🖥 ${vmsCount} VM
` : ''} ${dockerCount ? `
🐳 ${dockerCount} контейнеров
` : ''} ${volsHtml} ` : `
${m.error ? '⚠️ ' + m.error : 'Нет данных — нажмите 🔄'}
`}
${dev.tunnel?.enabled ? `` : ''}
`; }).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 = `
`; // System html += `

💻 Система

Модель: ${m.model || 'N/A'}
${m.dsm_version || ''}
Uptime: ${m.uptime || 'N/A'}
Температура: ${m.temperature || 0}°C
CPU: ${m.cpu_percent || 0}% | RAM: ${m.ram_percent || 0}% (${m.ram_used_mb || 0}/${m.ram_total_mb || 0} MB)

`; // Volumes if (m.volumes?.length) { html += `

💾 Тома

`; m.volumes.forEach(v => { const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : 'ok'; html += `
${v.name} — ${v.used_gb}/${v.total_gb} GB (${v.percent}%) ${v.status ? '• ' + v.status : ''}
`; }); html += `
`; } // Disks if (m.disks?.length) { html += `

🔩 Диски

`; m.disks.forEach(d => { html += `
${d.name} — ${d.model || ''} (${d.size_gb} GB) 🌡${d.temp}°C ${d.smart_status ? '• SMART: ' + d.smart_status : ''} ${d.status || ''}
`; }); html += `
`; } // VMs if (m.vms?.length) { html += `

🖥 Виртуальные машины

`; m.vms.forEach(vm => { const st = vm.status === 'running' ? '🟢' : '🔴'; html += `
${st} ${vm.name} — ${vm.vcpu} vCPU, ${vm.ram_mb} MB RAM ${vm.autorun ? '• AutoStart' : ''}
`; }); html += `
`; } // Docker if (m.docker?.length) { html += `

🐳 Docker контейнеры

`; m.docker.forEach(c => { const st = c.state === 'running' ? '🟢' : '🔴'; html += `
${st} ${c.name} — ${c.image || ''}
`; }); html += `
`; } html += `
`; 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 { const resp = await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'}); const data = await resp.json(); if (data.status === 'error' && data.detail) { console.warn('Synology refresh:', data.detail); } // Reload list with fresh data const listResp = await fetch('/api/synology/list', {credentials: 'include'}); synologyDevices = await listResp.json(); renderSynology(); } catch (e) { alert('Error: ' + e.message); } } async function refreshAllSynology() { try { await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'}); const resp = await fetch('/api/synology/list', {credentials: 'include'}); synologyDevices = await resp.json(); renderSynology(); } 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(); } async function showSynTunnel() { // Find first synology device with tunnel config const dev = synologyDevices.find(d => d.tunnel?.enabled) || synologyDevices[0]; if (!dev) { alert('Сначала добавьте Synology NAS'); return; } const name = dev.name; try { const resp = await fetch(`/api/synology/tunnel/setup-command?name=${encodeURIComponent(name)}`, {credentials: 'include'}); const data = await resp.json(); if (data.command) { document.getElementById('syn-tunnel-cmd').textContent = data.command; } } catch (e) { document.getElementById('syn-tunnel-cmd').textContent = 'Ошибка загрузки команды'; } document.getElementById('synTunnelModal').style.display = 'flex'; } // ===================== 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 = '
Нет Home Assistant. Нажмите "+ Добавить HA".
'; 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 `
${bellHtml('ha', inst.name)}
🏠 ${inst.name}
${m.location_name || inst.url || ''} ${m.version ? '• v' + m.version : ''}
${isOnline ? 'Online' : 'Offline'}
${isOnline ? `
📡 ${m.entities_count || 0} сущностей
${persons.length ? `
👤 ${persons.map(p => `${p.name}: ${p.state === 'home' ? '🏠' : '🚗'}`).join(', ')}
` : ''} ${temps.length ? `
🌡 ${temps.map(s => s.name.substring(0, 15) + ': ' + s.value + s.unit).join(' | ')}
` : ''} ${humidity.length ? `
💧 ${humidity.map(s => s.name.substring(0, 15) + ': ' + s.value + '%').join(' | ')}
` : ''} ${doors.length ? `
🚪 ${doors.filter(d => d.state === 'on').length ? '' + doors.filter(d => d.state === 'on').length + ' открыто' : 'все закрыты ✅'}
` : ''} ${motion.length ? `
🏃 ${motion.filter(m => m.state === 'on').length ? 'движение!' : 'нет движения'}
` : ''} ${battery.length ? `
🔋 ${battery.length} устройств с низким зарядом
` : ''} ${problems ? `
⚠️ ${problems} проблемных сущностей
` : ''} ${updates ? `
📦 ${updates} обновлений
` : ''}
` : '
Нет данных — нажмите 🔄
'}
`; }).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 = `
`; // System html += `

🔧 Система

Версия: ${m.version || 'N/A'}
Локация: ${m.location_name || 'N/A'}
Компоненты: ${m.components_count || 0}
Сущности: ${m.entities_count || 0}

`; // Temperatures const temps = m.sensors?.temperature || []; if (temps.length) { html += `

🌡️ Температура

`; 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 += `
${s.name}: ${s.value}${s.unit}
`; }); html += `
`; } // Humidity const humid = m.sensors?.humidity || []; if (humid.length) { html += `

💧 Влажность

`; humid.forEach(s => { if (s.value !== null) html += `
${s.name}: ${s.value}%
`; }); html += `
`; } // Doors/Windows const doors = m.sensors?.door || []; if (doors.length) { html += `

🚪 Двери / Окна

`; doors.forEach(d => { const icon = d.state === 'on' ? '🔓 Открыто' : '🔒 Закрыто'; const cls = d.state === 'on' ? 'style="color:var(--warning)"' : ''; html += `
${d.name}: ${icon}
`; }); html += `
`; } // Motion const motion = m.sensors?.motion || []; if (motion.length) { html += `

🏃 Движение

`; motion.forEach(s => { const cls = s.state === 'on' ? 'style="color:var(--warning)"' : ''; html += `
${s.name}: ${s.state === 'on' ? '⚡ Обнаружено' : '—'}
`; }); html += `
`; } // Battery const battery = m.sensors?.battery || []; if (battery.length) { html += `

🔋 Батареи

`; battery.forEach(b => { if (b.value === null) return; const cls = b.value < 20 ? 'crit' : b.value < 50 ? 'warn' : ''; html += `
${b.value}% ${b.name}
`; }); html += `
`; } // Climate if (m.climate?.length) { html += `

🌡 Климат

`; m.climate.forEach(c => { html += `
${c.name}: ${c.state} (${c.current_temp || '?'}° → ${c.target_temp || '?'}°) ${c.hvac_action}
`; }); html += `
`; } // Persons if (m.persons?.length) { html += `

👤 Люди

`; m.persons.forEach(p => { const icon = p.state === 'home' ? '🏠 Дома' : '🚗 Не дома'; html += `
${p.name}: ${icon}
`; }); html += `
`; } // Automations if (m.automations?.length) { html += `

⚙️ Автоматизации (${m.automations.length})

`; m.automations.slice(0, 20).forEach(a => { const st = a.state === 'on' ? '🟢' : '🔴'; html += `
${st} ${a.name}
`; }); html += `
`; } // Updates if (m.updates_available?.length) { html += `

📦 Доступные обновления

`; m.updates_available.forEach(u => { html += `
${u.name}: ${u.installed} → ${u.latest}
`; }); html += `
`; } // Problems if (m.problem_entities?.length) { html += `

⚠️ Проблемные сущности

`; m.problem_entities.slice(0, 20).forEach(p => { html += `
${p.entity_id}: ${p.state}
`; }); html += `
`; } html += `
`; 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'}); const resp = await fetch('/api/ha/list', {credentials: 'include'}); haInstances = await resp.json(); renderHA(); } catch (e) { alert('Error: ' + e.message); } } async function refreshAllHA() { try { await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'}); const resp = await fetch('/api/ha/list', {credentials: 'include'}); haInstances = await resp.json(); renderHA(); } 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 ? '✅ Настроен' : '❌ Не настроен (укажите Bot Token и Chat ID в ⚙️)'; } 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); } } // ===================== KEENETIC MONITORING ===================== let keeneticDevices = []; function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(/ { if (document.hidden) return; if (confirm('AnyDesk не открылся. Скачать с anydesk.com?')) { window.open('https://anydesk.com/en/downloads', '_blank'); } }, 1500); } async function loadKeenetic() { try { const resp = await fetch('/api/keenetic/list', {credentials: 'include'}); if (resp.status === 401) return; keeneticDevices = await resp.json(); renderKeenetic(); } catch (e) { console.error('Keenetic load error:', e); } } function renderKeenetic() { const grid = document.getElementById('keenetic-grid'); if (!keeneticDevices.length) { grid.innerHTML = '
Нет роутеров. Нажмите "+ Добавить роутер"
'; return; } grid.innerHTML = keeneticDevices.map(dev => { const m = dev.metrics || {}; const isOnline = m.online; const cpuload = m.cpuload || 0; const memPct = m.mem_percent || 0; const inet = m.internet; const uptime = m.uptime_str || ''; const model = m.model || ''; const firmware = m.firmware || ''; const error = m.error || ''; const vpns = m.vpn || []; const cpuClass = cpuload > 90 ? 'crit' : cpuload > 70 ? 'warn' : ''; const ramClass = memPct > 90 ? 'crit' : memPct > 70 ? 'warn' : ''; // VPN badges let vpnHtml = ''; if (vpns.length) { vpnHtml = `
` + vpns.map(v => { const up = v.state === 'up'; return `${v.type} ${v.description || v.name} ${up ? '🟢' : '🔴'}`; }).join('') + `
`; } const hostParts = [dev.host]; if (model) hostParts.push(model); if (firmware) hostParts.push('v' + firmware); const webUrl = keeneticWebUrl(dev); const anydeskId = (dev.anydesk || '').replace(/\D/g, ''); let linksHtml = ''; if (webUrl || anydeskId) { linksHtml = ''; } const urlEditId = 'keen-url-' + dev.name.replace(/[^a-zA-Z0-9_-]/g, '_'); const urlValue = escHtml(keeneticWebUrl(dev)); return `
${bellHtml('keenetic', dev.name)}
📡 ${dev.name}
${hostParts.join(' • ')}
${isOnline ? 'Online' : 'Offline'}
${linksHtml} ${vpnHtml} ${isOnline ? `
CPU ${cpuload}%
RAM ${memPct}%
${inet ? '🌐 Internet' : '⚠️ No Internet'} ${uptime ? ' • ⏱ ' + uptime : ''}
` : `
${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}
`}
`; }).join(''); } async function showKeeneticDetail(name) { const dev = keeneticDevices.find(d => d.name === name); if (!dev || !dev.metrics) return; const m = dev.metrics; document.getElementById('keeneticDetailTitle').textContent = `📡 ${dev.name}`; let html = `

Система

Статус${m.online ? '🟢 Online' : '🔴 Offline'}
Хост${dev.host}
Hostname${m.hostname || '-'}
Модель${m.model || '-'}
Прошивка${m.firmware || '-'}
CPU${m.cpuload || 0}%
RAM${m.mem_percent || 0}% (${formatBytes(m.memtotal - m.memfree)}/${formatBytes(m.memtotal)})
Uptime${m.uptime_str || '-'}

Интернет

Internet${m.internet ? '✅' : '❌'}
Gateway${m.gateway_accessible ? '✅' : '❌'}
DNS${m.dns_accessible ? '✅' : '❌'}
⏳ Загрузка клиентов и интерфейсов...
`; if (m.last_updated) { html += `
Обновлено: ${new Date(m.last_updated).toLocaleTimeString()}
`; } document.getElementById('keeneticDetailBody').innerHTML = html; document.getElementById('keeneticDetailModal').style.display = 'flex'; // Fetch clients + interfaces lazily try { const resp = await fetch(`/api/keenetic/detail/${name}`, {credentials: 'include'}); const data = await resp.json(); const extra = document.getElementById('keeneticDetailExtra'); if (!extra) return; let extraHtml = ''; // Clients const activeClients = (data.clients || []).filter(c => c.active); if (activeClients.length) { extraHtml += `

Подключенные устройства (${activeClients.length})

${activeClients.map(c => ` `).join('')}
ИмяIPMACТипSpeed
${c.name || c.hostname || '-'} ${c.ip} ${c.mac} ${c.ssid ? '📶 ' + c.ssid : '🔌 LAN'} ${c.speed ? c.speed + ' Mbps' : '-'}
`; } else { extraHtml += '
Нет активных клиентов
'; } // Interfaces if (data.interfaces && data.interfaces.length) { extraHtml += `

Интерфейсы

${data.interfaces.map(i => ` `).join('')}
IDТипСостояниеIP
${i.id} ${i.type} ${i.state === 'up' ? '🟢' : '🔴'} ${i.state} ${i.address || '-'}
`; } extra.innerHTML = extraHtml; extra.style.opacity = '1'; } catch (e) { const extra = document.getElementById('keeneticDetailExtra'); if (extra) extra.innerHTML = '
Ошибка загрузки деталей
'; } } function formatBytes(bytes) { if (!bytes || bytes <= 0) return '0'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB'; return (bytes / 1048576).toFixed(0) + ' MB'; } function showAddKeenetic() { document.getElementById('addKeeneticModal').style.display = 'flex'; document.getElementById('addKeeneticForm').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); const body = { name: fd.get('name'), host: fd.get('host'), web_url: fd.get('web_url') || fd.get('host'), anydesk: fd.get('anydesk') || '', login: fd.get('login') || 'admin', password: fd.get('password'), }; try { const resp = await fetch('/api/keenetic/add', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); const data = await resp.json(); if (data.status === 'ok') { closeModal('addKeeneticModal'); e.target.reset(); await refreshAllKeenetic(); } else { alert(data.detail || 'Error'); } } catch (err) { alert('Error: ' + err.message); } }; } function showImportKeenetic() { document.getElementById('importKeeneticResult').style.display = 'none'; document.getElementById('importKeeneticModal').style.display = 'flex'; document.getElementById('importKeeneticForm').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); const btn = e.target.querySelector('button[type=submit]'); btn.disabled = true; try { const resp = await fetch('/api/keenetic/import', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ tsv: fd.get('tsv'), login: fd.get('login') || 'admin', password: fd.get('password'), }), }); const data = await resp.json(); const resultDiv = document.getElementById('importKeeneticResult'); if (data.status === 'ok') { resultDiv.innerHTML = `✅ Добавлено: ${(data.added || []).join(', ') || '—'}` + (data.skipped?.length ? `
⏭ Пропущено: ${data.skipped.join(', ')}` : ''); resultDiv.style.display = 'block'; await refreshAllKeenetic(); } else { resultDiv.innerHTML = `❌ ${data.detail || 'Error'}`; resultDiv.style.display = 'block'; } } catch (err) { alert('Error: ' + err.message); } btn.disabled = false; }; } function showBulkAddKeenetic() { document.getElementById('bulkAddResult').style.display = 'none'; document.getElementById('bulkAddKeeneticModal').style.display = 'flex'; document.getElementById('bulkAddKeeneticForm').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); const body = { domains: fd.get('domains'), login: fd.get('login') || 'admin', password: fd.get('password'), }; const btn = e.target.querySelector('button[type=submit]'); btn.disabled = true; btn.textContent = '⏳ Добавление...'; try { const resp = await fetch('/api/keenetic/add-bulk', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); const data = await resp.json(); const resultDiv = document.getElementById('bulkAddResult'); if (data.status === 'ok') { let msg = `✅ Добавлено: ${data.added.length} роутеров`; if (data.added.length) msg += `
${data.added.join(', ')}`; if (data.skipped.length) msg += `
⏭ Пропущено (уже есть): ${data.skipped.join(', ')}`; resultDiv.innerHTML = msg; resultDiv.style.display = 'block'; e.target.querySelector('textarea').value = ''; await loadKeenetic(); } else { resultDiv.innerHTML = `❌ ${data.detail || 'Error'}`; resultDiv.style.display = 'block'; } } catch (err) { alert('Error: ' + err.message); } btn.disabled = false; btn.textContent = 'Добавить все'; }; } let keeneticRefreshCooldown = 0; async function refreshKeenetic(name) { const now = Date.now(); if (now < keeneticRefreshCooldown) { console.log('Keenetic refresh cooldown, skip'); return; } keeneticRefreshCooldown = now + 30000; // 30s cooldown const card = document.querySelector(`[data-keen="${name}"]`); if (card) card.classList.add('loading'); try { const resp = await fetch(`/api/keenetic/refresh/${name}`, { method: 'POST', credentials: 'include', }); const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'}); keeneticDevices = await resp2.json(); renderKeenetic(); } catch (e) { console.error('Keenetic refresh error:', e); } } async function refreshAllKeenetic() { const now = Date.now(); if (now < keeneticRefreshCooldown) { console.log('Keenetic refresh cooldown, skip'); return; } keeneticRefreshCooldown = now + 30000; document.querySelectorAll('#keenetic-grid .server-card').forEach(c => c.classList.add('loading')); const grid = document.getElementById('keenetic-grid'); if (!keeneticDevices.length) { grid.innerHTML = '
⏳ Загрузка...
'; } try { const resp = await fetch('/api/keenetic/refresh-all', { method: 'POST', credentials: 'include', }); const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'}); keeneticDevices = await resp2.json(); renderKeenetic(); } catch (e) { console.error('Keenetic refresh-all error:', e); } } async function rebootKeenetic(name) { if (!confirm(`Перезагрузить роутер "${name}"?`)) return; try { const resp = await fetch(`/api/keenetic/reboot/${name}`, { method: 'POST', credentials: 'include', }); const data = await resp.json(); if (data.status === 'ok') { alert(`✅ Команда перезагрузки отправлена: ${name}`); } else { alert(`❌ Ошибка: ${data.detail || 'unknown'}`); } } catch (e) { alert('Error: ' + e.message); } } function keeneticUrlEditId(name) { return 'keen-url-' + name.replace(/[^a-zA-Z0-9_-]/g, '_'); } function toggleKeeneticUrlEdit(name) { const block = document.getElementById(keeneticUrlEditId(name)); if (!block) return; const show = block.style.display === 'none'; block.style.display = show ? 'block' : 'none'; if (show) { const input = document.getElementById(keeneticUrlEditId(name) + '-input'); if (input) input.focus(); } } async function saveKeeneticUrl(name) { const input = document.getElementById(keeneticUrlEditId(name) + '-input'); if (!input) return; const web_url = input.value.trim(); if (!web_url) { alert('Укажите адрес KeenDNS'); return; } const saveBtn = input.closest('.keenetic-url-edit')?.querySelector('.btn-primary'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '⏳ Сохранение...'; } try { const resp = await fetch(`/api/keenetic/${encodeURIComponent(name)}`, { method: 'PATCH', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({web_url, refresh: true}), }); const data = await resp.json(); if (data.status !== 'ok') { alert('❌ ' + (data.detail || 'Ошибка сохранения')); return; } await loadKeenetic(); toggleKeeneticUrlEdit(name); } catch (e) { alert('Ошибка: ' + e.message); } finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Сохранить'; } } } async function deleteKeenetic(name) { if (!confirm(`Удалить роутер "${name}"?`)) return; try { await fetch(`/api/keenetic/${name}`, { method: 'DELETE', credentials: 'include', }); await loadKeenetic(); } catch (e) { alert('Error: ' + e.message); } } // ===================== INIT ===================== document.addEventListener('DOMContentLoaded', () => { applyTheme(currentTheme); loadMutedDevices().then(() => { renderPage(); loadServers(); }); }); setInterval(() => { if (activeTab === 'servers') loadServers(); else if (activeTab === 'pc') loadPCs(); else if (activeTab === 'synology') loadSynology(); else if (activeTab === 'ha') loadHA(); else if (activeTab === 'keenetic') loadKeenetic(); }, 30000);