mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
Card-level onclick fired even when KeenDNS was clicked. Use delegated handlers so the edit form toggles reliably and card clicks skip buttons. Co-authored-by: Cursor <cursoragent@cursor.com>
1661 lines
69 KiB
JavaScript
1661 lines
69 KiB
JavaScript
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 `<button class="mute-bell${isMuted ? ' muted' : ''}" data-cat="${category}" data-name="${esc}" title="${isMuted ? 'Уведомления выключены' : 'Уведомления включены'}">${isMuted ? '🔕' : '🔔'}</button>`;
|
||
}
|
||
|
||
// 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 = `<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">${t('noServers')}</div>`;
|
||
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 `
|
||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showServerDetail('${id}')">
|
||
${bellHtml('servers', srv.name)}
|
||
<div class="server-card-header">
|
||
<div>
|
||
<div class="name">${srv.name}</div>
|
||
<div class="host">${srv.host}:${srv.port || 22}</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">Disk</span>
|
||
<span class="value ${diskClass}">${disk}%</span>
|
||
<div class="progress-bar"><div class="fill ${diskClass || 'ok'}" style="width:${disk}%"></div></div>
|
||
</div>
|
||
</div>
|
||
` : `<div style="padding:20px 0;text-align:center;opacity:0.5">${t('noData')}</div>`}
|
||
<div class="server-card-actions">
|
||
<button onclick="event.stopPropagation(); openSSH('${id}', '${srv.name}')">💻 SSH</button>
|
||
<button onclick="event.stopPropagation(); rebootServer('${id}')">🔄 Reboot</button>
|
||
<button class="danger" onclick="event.stopPropagation(); deleteServer('${id}')">🗑</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div style="display:grid;gap:12px">
|
||
<div class="form-group">
|
||
<label>Host</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px;font-family:monospace">${srv.host}:${srv.port || 22}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${t('status')}</label>
|
||
<div><span class="status-badge ${m.online ? 'online' : 'offline'}">
|
||
<span class="status-dot ${m.online ? 'online' : 'offline'}"></span>
|
||
${m.online ? 'Online' : 'Offline'}
|
||
</span></div>
|
||
</div>
|
||
${m.online ? `
|
||
<div class="form-group">
|
||
<label>${t('uptime')}</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">${m.uptime || 'N/A'}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${t('load')}</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">${m.load_average || 'N/A'}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>RAM</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">${(m.ram_used_mb||0).toFixed(0)} / ${(m.ram_total_mb||0).toFixed(0)} MB (${m.ram_percent||0}%)</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Disk</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">${(m.disk_used_gb||0).toFixed(1)} / ${(m.disk_total_gb||0).toFixed(1)} GB (${m.disk_percent||0}%)</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${t('network')}</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">↓ ${(m.network_in_mb||0).toFixed(0)} MB / ↑ ${(m.network_out_mb||0).toFixed(0)} MB</div>
|
||
</div>
|
||
` : ''}
|
||
${srv.description ? `
|
||
<div class="form-group">
|
||
<label>${t('description')}</label>
|
||
<div style="padding:8px;background:var(--input-bg);border-radius:6px">${srv.description}</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
// Instructions
|
||
function showInstructions() {
|
||
const content = document.getElementById('instructions-content');
|
||
content.innerHTML = `
|
||
<div class="instructions-section">
|
||
<div class="instr-card">
|
||
<h4>📥 ${t('instr_install')}</h4>
|
||
<p>${t('instr_add_server_text').includes('Click') ? 'One command to install on any Ubuntu/Debian server:' : 'Одна команда для установки на Ubuntu/Debian:'}</p>
|
||
<code>${t('instr_install_text')}</code>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>➕ ${t('instr_add_server')}</h4>
|
||
<p>${t('instr_add_server_text')}</p>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>💻 ${t('instr_ssh')}</h4>
|
||
<p>${t('instr_ssh_text')}</p>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>🤖 ${t('instr_telegram')}</h4>
|
||
<p>${t('instr_telegram_text')}</p>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>🔄 ${t('instr_reboot')}</h4>
|
||
<p>${t('instr_reboot_text')}</p>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>⚠️ ${t('instr_thresholds')}</h4>
|
||
<p>${t('instr_thresholds_text')}</p>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>🔧 ${t('instr_commands')}</h4>
|
||
<ul class="cmd-list">
|
||
<li>📊 ${t('instr_cmd_status')}</li>
|
||
<li>🔄 ${t('instr_cmd_restart')}</li>
|
||
<li>📋 ${t('instr_cmd_logs')}</li>
|
||
<li>⬆️ ${t('instr_cmd_update')}</li>
|
||
</ul>
|
||
</div>
|
||
<div class="instr-card">
|
||
<h4>🔌 ${t('instr_api')}</h4>
|
||
<ul class="cmd-list">
|
||
<li>GET /api/servers — list all</li>
|
||
<li>POST /api/servers — add server</li>
|
||
<li>DELETE /api/servers/{id} — remove</li>
|
||
<li>POST /api/servers/{id}/reboot — reboot</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>
|
||
</div>
|
||
`;
|
||
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 = '<div style="padding:20px;color:var(--danger)">xterm.js not loaded</div>';
|
||
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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Нажмите "📥 Скачать агент".</div>';
|
||
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 =>
|
||
`<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">${d.drive} ${d.used_gb}/${d.total_gb} GB (${d.percent}%)</div>`
|
||
).join('');
|
||
|
||
const procsHtml = (m.top_processes || []).slice(0, 3).map(p =>
|
||
`<span style="font-size:10px;padding:2px 6px;background:var(--bg-primary);border-radius:4px;margin:1px">${p.name} ${p.ram_mb}MB</span>`
|
||
).join(' ');
|
||
|
||
return `
|
||
<div class="server-card ${isOnline ? 'online' : 'offline'}">
|
||
${bellHtml('pc', pc.agent_name)}
|
||
<div class="server-card-header">
|
||
<div>
|
||
<div class="name">💻 ${pc.agent_name}</div>
|
||
<div class="host">${m.hostname || ''} • ${pc.ip || ''}</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">Disk</span>
|
||
<span class="value ${diskClass}">${disk}%</span>
|
||
<div class="progress-bar"><div class="fill ${diskClass || 'ok'}" style="width:${disk}%"></div></div>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||
${m.uptime || ''} ${m.os ? '• ' + m.os.substring(0, 30) : ''}
|
||
</div>
|
||
${m.gpu_name ? `<div style="font-size:11px;color:var(--text-secondary)">GPU: ${m.gpu_name}</div>` : ''}
|
||
${disksHtml}
|
||
${procsHtml ? `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:2px">${procsHtml}</div>` : ''}
|
||
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных</div>'}
|
||
<div class="server-card-actions">
|
||
<button class="danger" onclick="deletePC('${pc.agent_name}')">🗑 Удалить</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = '<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}')">
|
||
${bellHtml('synology', dev.name)}
|
||
<div class="server-card-header">
|
||
<div>
|
||
<div class="name">📦 ${dev.name}</div>
|
||
<div class="host">${dev.tunnel?.enabled ? '🔗 tunnel' : 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">${m.error ? '⚠️ ' + m.error : 'Нет данных — нажмите 🔄'}</div>`}
|
||
<div class="server-card-actions">
|
||
<button onclick="event.stopPropagation(); refreshSynology('${dev.name}')">🔄 Обновить</button>
|
||
${dev.tunnel?.enabled ? `<button onclick="event.stopPropagation(); showSynTunnel()">🔗 Туннель</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 {
|
||
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 = '<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}')">
|
||
${bellHtml('ha', 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'});
|
||
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
|
||
? '<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); }
|
||
}
|
||
|
||
// ===================== KEENETIC MONITORING =====================
|
||
let keeneticDevices = [];
|
||
|
||
function escHtml(s) {
|
||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
function keeneticWebUrl(dev) {
|
||
let u = (dev.web_url || '').trim();
|
||
if (!u && dev.host) {
|
||
const h = dev.host.trim();
|
||
u = h.startsWith('http') ? h : (/^\d+\.\d+/.test(h) ? 'http://' : 'https://') + h;
|
||
}
|
||
if (u && !/^https?:\/\//i.test(u)) u = 'https://' + u;
|
||
return u.replace(/\/$/, '');
|
||
}
|
||
|
||
function openAnyDesk(id, ev) {
|
||
if (ev) ev.preventDefault();
|
||
const proto = 'anydesk://' + id;
|
||
window.location.href = proto;
|
||
setTimeout(() => {
|
||
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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет роутеров. Нажмите "+ Добавить роутер"</div>';
|
||
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 = `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px">` +
|
||
vpns.map(v => {
|
||
const up = v.state === 'up';
|
||
return `<span class="badge ${up ? 'badge-vpn-up' : 'badge-vpn-down'}">${v.type} ${v.description || v.name} ${up ? '🟢' : '🔴'}</span>`;
|
||
}).join('') + `</div>`;
|
||
}
|
||
|
||
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 = '<div class="keenetic-links" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
|
||
if (webUrl) {
|
||
linksHtml += `<a class="btn-secondary" style="padding:4px 10px;font-size:12px;text-decoration:none" href="${escHtml(webUrl)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">🌐 Веб</a>`;
|
||
}
|
||
if (anydeskId) {
|
||
linksHtml += `<a class="btn-secondary" style="padding:4px 10px;font-size:12px;text-decoration:none;cursor:pointer" href="anydesk://${escHtml(anydeskId)}" onclick="event.stopPropagation();openAnyDesk('${escHtml(anydeskId)}',event)">🖥 AnyDesk ${escHtml(anydeskId)}</a>`;
|
||
}
|
||
linksHtml += '</div>';
|
||
}
|
||
|
||
const urlEditId = 'keen-url-' + dev.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||
const urlValue = escHtml(keeneticWebUrl(dev));
|
||
|
||
return `
|
||
<div class="server-card ${isOnline ? 'online' : 'offline'}" data-keen="${escHtml(dev.name)}">
|
||
${bellHtml('keenetic', dev.name)}
|
||
<div class="server-card-header keenetic-card-open">
|
||
<div>
|
||
<div class="name">📡 ${escHtml(dev.name)}</div>
|
||
<div class="host">${hostParts.join(' • ')}</div>
|
||
</div>
|
||
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||
${isOnline ? 'Online' : 'Offline'}
|
||
</span>
|
||
</div>
|
||
${linksHtml}
|
||
${vpnHtml}
|
||
${isOnline ? `
|
||
<div class="metrics-grid" style="grid-template-columns: repeat(2, 1fr)">
|
||
<div class="metric-item">
|
||
<span class="label">CPU</span>
|
||
<span class="value ${cpuClass}">${cpuload}%</span>
|
||
<div class="progress-bar"><div class="fill ${cpuClass || 'ok'}" style="width:${cpuload}%"></div></div>
|
||
</div>
|
||
<div class="metric-item">
|
||
<span class="label">RAM</span>
|
||
<span class="value ${ramClass}">${memPct}%</span>
|
||
<div class="progress-bar"><div class="fill ${ramClass || 'ok'}" style="width:${memPct}%"></div></div>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||
${inet ? '🌐 Internet' : '<span style="color:var(--danger)">⚠️ No Internet</span>'}
|
||
${uptime ? ' • ⏱ ' + uptime : ''}
|
||
</div>
|
||
` : `<div style="padding:20px 0;text-align:center;opacity:0.5">${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}</div>`}
|
||
<div id="${urlEditId}" class="keenetic-url-edit" style="display:none">
|
||
<div class="keenetic-url-edit-title">✏️ Адрес KeenDNS</div>
|
||
<div class="form-group" style="margin-bottom:8px">
|
||
<label for="${urlEditId}-input">Адрес KeenDNS</label>
|
||
<input type="text" id="${urlEditId}-input" value="${urlValue}" placeholder="https://example.keenetic.pro:8443">
|
||
</div>
|
||
<button type="button" class="btn-primary keenetic-url-save" style="width:100%" data-name="${escHtml(dev.name)}">Сохранить</button>
|
||
</div>
|
||
<div class="server-card-actions">
|
||
<button type="button" class="keenetic-url-btn" data-name="${escHtml(dev.name)}">✏️ KeenDNS</button>
|
||
<button type="button" onclick="event.stopPropagation();refreshKeenetic(${JSON.stringify(dev.name)})">🔄 Обновить</button>
|
||
<button type="button" onclick="event.stopPropagation();rebootKeenetic(${JSON.stringify(dev.name)})">🔁 Reboot</button>
|
||
<button type="button" class="danger" onclick="event.stopPropagation();deleteKeenetic(${JSON.stringify(dev.name)})">🗑</button>
|
||
</div>
|
||
</div>`;
|
||
}).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 = `
|
||
<div class="detail-grid">
|
||
<div class="detail-section">
|
||
<h3>Система</h3>
|
||
<table class="detail-table">
|
||
<tr><td>Статус</td><td>${m.online ? '🟢 Online' : '🔴 Offline'}</td></tr>
|
||
<tr><td>Хост</td><td>${dev.host}</td></tr>
|
||
<tr><td>Hostname</td><td>${m.hostname || '-'}</td></tr>
|
||
<tr><td>Модель</td><td>${m.model || '-'}</td></tr>
|
||
<tr><td>Прошивка</td><td>${m.firmware || '-'}</td></tr>
|
||
<tr><td>CPU</td><td>${m.cpuload || 0}%</td></tr>
|
||
<tr><td>RAM</td><td>${m.mem_percent || 0}% (${formatBytes(m.memtotal - m.memfree)}/${formatBytes(m.memtotal)})</td></tr>
|
||
<tr><td>Uptime</td><td>${m.uptime_str || '-'}</td></tr>
|
||
</table>
|
||
</div>
|
||
<div class="detail-section">
|
||
<h3>Интернет</h3>
|
||
<table class="detail-table">
|
||
<tr><td>Internet</td><td>${m.internet ? '✅' : '❌'}</td></tr>
|
||
<tr><td>Gateway</td><td>${m.gateway_accessible ? '✅' : '❌'}</td></tr>
|
||
<tr><td>DNS</td><td>${m.dns_accessible ? '✅' : '❌'}</td></tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div id="keeneticDetailExtra" style="margin-top:16px;opacity:0.5;text-align:center">⏳ Загрузка клиентов и интерфейсов...</div>`;
|
||
|
||
if (m.last_updated) {
|
||
html += `<div style="margin-top:12px;opacity:0.5;font-size:12px">Обновлено: ${new Date(m.last_updated).toLocaleTimeString()}</div>`;
|
||
}
|
||
|
||
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 += `<div class="detail-section">
|
||
<h3>Подключенные устройства (${activeClients.length})</h3>
|
||
<table class="detail-table">
|
||
<tr><th>Имя</th><th>IP</th><th>MAC</th><th>Тип</th><th>Speed</th></tr>
|
||
${activeClients.map(c => `
|
||
<tr>
|
||
<td>${c.name || c.hostname || '-'}</td>
|
||
<td>${c.ip}</td>
|
||
<td style="font-size:11px">${c.mac}</td>
|
||
<td>${c.ssid ? '📶 ' + c.ssid : '🔌 LAN'}</td>
|
||
<td>${c.speed ? c.speed + ' Mbps' : '-'}</td>
|
||
</tr>`).join('')}
|
||
</table>
|
||
</div>`;
|
||
} else {
|
||
extraHtml += '<div style="opacity:0.5">Нет активных клиентов</div>';
|
||
}
|
||
|
||
// Interfaces
|
||
if (data.interfaces && data.interfaces.length) {
|
||
extraHtml += `<div class="detail-section" style="margin-top:16px">
|
||
<h3>Интерфейсы</h3>
|
||
<table class="detail-table">
|
||
<tr><th>ID</th><th>Тип</th><th>Состояние</th><th>IP</th></tr>
|
||
${data.interfaces.map(i => `
|
||
<tr>
|
||
<td>${i.id}</td>
|
||
<td>${i.type}</td>
|
||
<td>${i.state === 'up' ? '🟢' : '🔴'} ${i.state}</td>
|
||
<td>${i.address || '-'}</td>
|
||
</tr>`).join('')}
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
extra.innerHTML = extraHtml;
|
||
extra.style.opacity = '1';
|
||
} catch (e) {
|
||
const extra = document.getElementById('keeneticDetailExtra');
|
||
if (extra) extra.innerHTML = '<div style="color:#ef4444">Ошибка загрузки деталей</div>';
|
||
}
|
||
}
|
||
|
||
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 ? `<br>⏭ Пропущено: ${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 += `<br><b>${data.added.join(', ')}</b>`;
|
||
if (data.skipped.length) msg += `<br>⏭ Пропущено (уже есть): ${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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">⏳ Загрузка...</div>';
|
||
}
|
||
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);
|