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);
return;
}
const keenDnsBtn = e.target.closest('.keenetic-url-btn');
if (keenDnsBtn) {
e.stopPropagation();
e.preventDefault();
toggleKeeneticUrlEdit(keenDnsBtn.dataset.name);
return;
}
const keenSaveBtn = e.target.closest('.keenetic-url-save');
if (keenSaveBtn) {
e.stopPropagation();
e.preventDefault();
saveKeeneticUrl(keenSaveBtn.dataset.name);
return;
}
const keenCard = e.target.closest('.server-card[data-keen]');
if (keenCard && !e.target.closest('.mute-bell, .server-card-actions, .keenetic-url-edit, .keenetic-links, a, button, input, label')) {
showKeeneticDetail(keenCard.dataset.keen);
}
});
function bellHtml(category, name) {
const key = `${category}:${name}`;
const isMuted = mutedDevices.has(key);
const esc = name.replace(/"/g, '"');
return `${isMuted ? '🔕' : '🔔'} `;
}
// 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)}
${isOnline ? `
` : `
${t('noData')}
`}
💻 SSH
🔄 Reboot
🗑
`;
}).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 = `
${m.online ? `
` : ''}
${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_cmd_status')}
🔄 ${t('instr_cmd_restart')}
📋 ${t('instr_cmd_logs')}
⬆️ ${t('instr_cmd_update')}
🔌 ${t('instr_api')}
GET /api/servers — list all
POST /api/servers — add server
DELETE /api/servers/{id} — remove
POST /api/servers/{id}/reboot — reboot
GET /api/synology/list — Synology NAS
GET /api/ha/list — Home Assistant
GET /api/pc/list — PC agents
GET /api/notifications/settings
WS /ws/ssh/{id} — SSH terminal
`;
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)}
${isOnline ? `
${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)}
${isOnline ? `
${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)}
${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)}
${linksHtml}
${vpnHtml}
${isOnline ? `
${inet ? '🌐 Internet' : '⚠️ No Internet '}
${uptime ? ' • ⏱ ' + uptime : ''}
` : `
${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}
`}
✏️ KeenDNS
🔄 Обновить
🔁 Reboot
🗑
`;
}).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})
Имя IP MAC Тип Speed
${activeClients.map(c => `
${c.name || c.hostname || '-'}
${c.ip}
${c.mac}
${c.ssid ? '📶 ' + c.ssid : '🔌 LAN'}
${c.speed ? c.speed + ' Mbps' : '-'}
`).join('')}
`;
} else {
extraHtml += 'Нет активных клиентов
';
}
// Interfaces
if (data.interfaces && data.interfaces.length) {
extraHtml += `
Интерфейсы
ID Тип Состояние IP
${data.interfaces.map(i => `
${i.id}
${i.type}
${i.state === 'up' ? '🟢' : '🔴'} ${i.state}
${i.address || '-'}
`).join('')}
`;
}
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.classList.contains('is-open');
block.style.display = show ? 'block' : 'none';
block.classList.toggle('is-open', show);
const card = document.querySelector(`.server-card[data-keen="${CSS.escape(name)}"]`);
if (card) {
card.querySelectorAll('.keenetic-url-edit.is-open').forEach(el => {
if (el !== block) {
el.style.display = 'none';
el.classList.remove('is-open');
}
});
card.querySelector('.keenetic-url-btn')?.classList.toggle('active', show);
}
if (show) {
const input = document.getElementById(keeneticUrlEditId(name) + '-input');
if (input) {
input.focus();
input.select();
}
block.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
}
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);