/** * DHCP Lease Manager — client-side logic. * All API URLs are relative to the page path, so the Zoraxy proxy handles routing. */ const API_BASE = './api'; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function showToast(message, type) { const toast = document.getElementById('toast'); toast.textContent = message; toast.className = 'toast ' + type; void toast.offsetWidth; // force reflow for transition toast.classList.remove('hidden'); setTimeout(function () { toast.classList.add('hidden'); }, 3000); } /** Minimal fetch wrapper that includes the CSRF token. */ async function apiFetch(path, opts) { opts = opts || {}; opts.headers = opts.headers || {}; var method = (opts.method || 'GET').toUpperCase(); var isPost = (method === 'POST' || method === 'PUT' || method === 'PATCH'); // Attach CSRF token as query param (Zoraxy may validate it there) if (csrfToken && csrfToken !== '{{.csrfToken}}') { var sep = path.indexOf('?') >= 0 ? '&' : '?'; path = path + sep + 'csrfToken=' + encodeURIComponent(csrfToken); opts.headers['X-Zoraxy-Csrf'] = csrfToken; } if (isPost && opts.body && typeof opts.body === 'object') { // Send as URL-encoded form data (Zoraxy's CSRF middleware parses forms, not JSON) var formBody = new URLSearchParams(); for (var key in opts.body) { if (opts.body.hasOwnProperty(key)) { formBody.append(key, opts.body[key]); } } formBody.append('csrfToken', csrfToken || ''); opts.body = formBody.toString(); opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; } var res = await fetch(API_BASE + path, opts); var text = await res.text(); try { var data = JSON.parse(text); } catch (e) { throw new Error('Unexpected response (status ' + res.status + '). Check plugin is running.'); } if (!res.ok) { throw new Error(data.error || data.message || 'Request failed (status ' + res.status + ')'); } return data; } function formatTime(t) { var d = new Date(t); if (isNaN(d.getTime())) return '--'; return d.toLocaleString(); } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- function renderLeases(data) { var tbody = document.getElementById('leases-body'); var leases = data.leases || []; document.getElementById('active-count').textContent = data.active_count; document.getElementById('pinned-count').textContent = data.pinned_count; if (leases.length === 0) { tbody.innerHTML = 'No active leases'; return; } var rows = leases.map(function (lease) { var isPermanent = lease.status === 'permanent'; return ( '' + '' + esc(lease.hostname || '--') + '' + '' + esc(lease.ip) + '' + '' + esc(lease.mac) + '' + '' + '' + esc(lease.status) + '' + '' + '' + (isPermanent ? '' : '' ) + '' + '' ); }).join(''); tbody.innerHTML = rows; } function esc(s) { if (!s) return ''; return s.replace(/&/g, '&').replace(//g, '>'); } function escAttr(s) { if (!s) return ''; return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- async function loadLeases() { try { var data = await apiFetch('/leases'); renderLeases(data); } catch (err) { showToast('Failed to load leases: ' + err.message, 'error'); } } async function pinLease(mac, ip, hostname) { try { await apiFetch('/pin', { method: 'POST', body: { mac: mac, ip: ip, hostname: hostname || '' } }); showToast('Pinned ' + (hostname || mac), 'success'); loadLeases(); } catch (err) { showToast('Pin failed: ' + err.message, 'error'); } } async function unpinLease(mac, ip, hostname) { try { await apiFetch('/unpin', { method: 'POST', body: { mac: mac, ip: ip, hostname: hostname || '' } }); showToast('Unpinned ' + (hostname || mac), 'success'); loadLeases(); } catch (err) { showToast('Unpin failed: ' + err.message, 'error'); } } async function reloadDnsmasq() { var btn = document.getElementById('reload-btn'); btn.disabled = true; btn.textContent = 'Reloading...'; try { await apiFetch('/reload', { method: 'POST' }); showToast('dnsmasq reloaded', 'success'); loadLeases(); } catch (err) { showToast('Reload failed: ' + err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Reload dnsmasq'; } } // --------------------------------------------------------------------------- // Event delegation // --------------------------------------------------------------------------- document.getElementById('leases-body').addEventListener('click', function (e) { var btn = e.target.closest('button[data-action]'); if (!btn) return; var action = btn.dataset.action; var mac = btn.dataset.mac; var ip = btn.dataset.ip; var hostname = btn.dataset.hostname; if (action === 'pin') { pinLease(mac, ip, hostname); } else if (action === 'unpin') { unpinLease(mac, ip, hostname); } }); document.getElementById('reload-btn').addEventListener('click', reloadDnsmasq); // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- loadLeases();