// ── Constants ────────────────────────────────────────────────────────────── const API = "/api/aps"; const FREQ_BANDS = [ { key: "900", label: "900 MHz", min: 800, max: 999, color: "#805ad5", bg: "#553c9a" }, { key: "2400", label: "2.4 GHz", min: 2400, max: 2500, color: "#4299e1", bg: "#2a4365" }, { key: "5800", label: "5 GHz", min: 5000, max: 6000, color: "#48bb78", bg: "#22543d" }, { key: "6000", label: "6 GHz", min: 6000, max: 7000, color: "#f6ad55", bg: "#744210" }, { key: "other", label: "Other", min: 0, max: 99999,color: "#a0aec0", bg: "#2d3748" }, ]; function bandForFreq(mhz) { for (const b of FREQ_BANDS.slice(0, -1)) { if (mhz >= b.min && mhz < b.max) return b; } return FREQ_BANDS[FREQ_BANDS.length - 1]; } function freqLabel(mhz) { if (mhz >= 1000) return (mhz / 1000).toFixed(1) + " GHz"; return mhz + " MHz"; } // Signal strength → 0-100 quality (dBm, typical range -40 to -90) function signalQuality(dbm) { return Math.max(0, Math.min(100, Math.round(((dbm + 90) / 50) * 100))); } function signalColor(q) { if (q > 70) return "#48bb78"; if (q > 40) return "#f6ad55"; return "#fc8181"; } // ── State ────────────────────────────────────────────────────────────────── let aps = []; let activeFilters = new Set(["900", "2400", "5800", "6000", "other"]); let selectedId = null; let markers = {}; // id → L.marker let coverages = {}; // id → L.circle or L.polygon let editingId = null; let mapClickLat = null; let mapClickLon = null; // ── Map setup ────────────────────────────────────────────────────────────── const map = L.map("map", { zoomControl: false }).setView([46.7324, -117.0002], 13); L.control.zoom({ position: "bottomright" }).addTo(map); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(map); // Click map to pre-fill lat/lon in add form map.on("click", (e) => { mapClickLat = e.latlng.lat.toFixed(6); mapClickLon = e.latlng.lng.toFixed(6); }); // ── API helpers ──────────────────────────────────────────────────────────── async function fetchAPs() { const res = await fetch(API); aps = await res.json(); renderAll(); } async function saveAP(data) { const url = editingId ? `${API}/${editingId}` : API; const method = editingId ? "PUT" : "POST"; const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); alert("Error: " + (err.error || "Unknown error")); return; } closeModal(); await fetchAPs(); } async function deleteAP(id) { if (!confirm("Delete this access point?")) return; await fetch(`${API}/${id}`, { method: "DELETE" }); closeModal(); selectedId = null; await fetchAPs(); } // ── Marker / Coverage drawing ────────────────────────────────────────────── function clearMapObjects() { Object.values(markers).forEach(m => map.removeLayer(m)); Object.values(coverages).forEach(c => map.removeLayer(c)); markers = {}; coverages = {}; } function makeIcon(ap) { const band = bandForFreq(ap.frequency); const size = selectedId === ap.id ? 22 : 16; const border = selectedId === ap.id ? 3 : 2; const svg = ` ${ap.antenna_type === "sector" ? `` : `` } `; return L.divIcon({ html: svg, className: "", iconSize: [size, size], iconAnchor: [size/2, size/2], popupAnchor: [0, -size/2], }); } function drawCoverage(ap) { const band = bandForFreq(ap.frequency); const color = band.color; const opacity = selectedId === ap.id ? 0.18 : 0.09; const strokeOpacity = selectedId === ap.id ? 0.6 : 0.35; let layer; if (ap.antenna_type === "sector" && ap.beamwidth < 355) { layer = sectorPolygon(ap.lat, ap.lon, ap.coverage_radius, ap.azimuth, ap.beamwidth, color, opacity, strokeOpacity); } else { layer = L.circle([ap.lat, ap.lon], { radius: ap.coverage_radius, color, weight: 1.5, fillColor: color, fillOpacity: opacity, opacity: strokeOpacity, }); } layer.addTo(map); layer.on("click", () => selectAP(ap.id)); return layer; } function sectorPolygon(lat, lon, radius, azimuth, beamwidth, color, fillOpacity, opacity) { const points = [[lat, lon]]; const startAngle = azimuth - beamwidth / 2; const steps = Math.max(16, Math.round(beamwidth)); for (let i = 0; i <= steps; i++) { const angle = ((startAngle + (beamwidth * i) / steps) * Math.PI) / 180; const dlat = (radius / 111320) * Math.cos(angle); const dlon = (radius / (111320 * Math.cos((lat * Math.PI) / 180))) * Math.sin(angle); points.push([lat + dlat, lon + dlon]); } points.push([lat, lon]); return L.polygon(points, { color, weight: 1.5, fillColor: color, fillOpacity, opacity, }); } function popupContent(ap) { const band = bandForFreq(ap.frequency); const q = signalQuality(ap.signal_strength); const sColor = signalColor(q); return `
${ap.ssid ? `` : ""} ${ap.channel ? `` : ""}
Signal: ${ap.signal_strength} dBm (${q}%)
${ap.notes ? `` : ""}
`; } function renderMapObjects() { clearMapObjects(); const visible = aps.filter(ap => activeFilters.has(bandForFreq(ap.frequency).key)); // Draw coverages first (below markers) visible.forEach(ap => { coverages[ap.id] = drawCoverage(ap); }); // Draw markers visible.forEach(ap => { const marker = L.marker([ap.lat, ap.lon], { icon: makeIcon(ap) }); marker.bindPopup(popupContent(ap), { maxWidth: 260 }); marker.on("click", () => selectAP(ap.id)); marker.addTo(map); markers[ap.id] = marker; }); } // ── Sidebar ──────────────────────────────────────────────────────────────── function renderSidebar() { const list = document.getElementById("ap-list"); const countEl = document.getElementById("ap-count"); const visible = aps.filter(ap => activeFilters.has(bandForFreq(ap.frequency).key)); countEl.textContent = visible.length; list.innerHTML = ""; visible.forEach(ap => { const band = bandForFreq(ap.frequency); const item = document.createElement("div"); item.className = "ap-item" + (ap.id === selectedId ? " selected" : ""); item.dataset.id = ap.id; item.innerHTML = `
${ap.name}
${ap.lat.toFixed(4)}, ${ap.lon.toFixed(4)} · ${ap.coverage_radius >= 1000 ? (ap.coverage_radius/1000).toFixed(1)+"km" : ap.coverage_radius+"m"}
${freqLabel(ap.frequency)}
`; item.addEventListener("click", () => selectAP(ap.id)); list.appendChild(item); }); } function renderAll() { renderSidebar(); renderMapObjects(); } // ── Selection ────────────────────────────────────────────────────────────── function selectAP(id) { selectedId = id; renderAll(); const ap = aps.find(a => a.id === id); if (ap && markers[id]) { map.setView([ap.lat, ap.lon], Math.max(map.getZoom(), 14), { animate: true }); markers[id].openPopup(); } // Scroll sidebar item into view const el = document.querySelector(`.ap-item[data-id="${id}"]`); if (el) el.scrollIntoView({ block: "nearest" }); } // ── Filters ──────────────────────────────────────────────────────────────── document.querySelectorAll(".filter-btn").forEach(btn => { btn.addEventListener("click", () => { const key = btn.dataset.freq; if (activeFilters.has(key)) { activeFilters.delete(key); btn.classList.remove("active"); } else { activeFilters.add(key); btn.classList.add("active"); } renderAll(); }); }); // ── Modal ────────────────────────────────────────────────────────────────── const modalOverlay = document.getElementById("modal-overlay"); const form = document.getElementById("ap-form"); function openAddModal() { editingId = null; document.getElementById("modal-title").textContent = "Add Access Point"; document.getElementById("btn-delete-ap").style.display = "none"; form.reset(); if (mapClickLat) { document.getElementById("f-lat").value = mapClickLat; document.getElementById("f-lon").value = mapClickLon; } document.getElementById("f-beamwidth").value = 360; document.getElementById("f-radius").value = 2000; document.getElementById("f-signal").value = -65; document.getElementById("f-height").value = 30; document.getElementById("f-azimuth").value = 0; toggleAntennaFields(); modalOverlay.classList.add("open"); } function openEditModal(id) { editingId = id; const ap = aps.find(a => a.id === id); if (!ap) return; document.getElementById("modal-title").textContent = "Edit Access Point"; document.getElementById("btn-delete-ap").style.display = "block"; document.getElementById("f-name").value = ap.name; document.getElementById("f-ssid").value = ap.ssid || ""; document.getElementById("f-lat").value = ap.lat; document.getElementById("f-lon").value = ap.lon; document.getElementById("f-freq").value = ap.frequency; document.getElementById("f-channel").value = ap.channel || ""; document.getElementById("f-antenna").value = ap.antenna_type; document.getElementById("f-azimuth").value = ap.azimuth; document.getElementById("f-beamwidth").value = ap.beamwidth; document.getElementById("f-radius").value = ap.coverage_radius; document.getElementById("f-signal").value = ap.signal_strength; document.getElementById("f-height").value = ap.height; document.getElementById("f-notes").value = ap.notes || ""; toggleAntennaFields(); modalOverlay.classList.add("open"); } function closeModal() { modalOverlay.classList.remove("open"); editingId = null; } function toggleAntennaFields() { const isSector = document.getElementById("f-antenna").value === "sector"; document.getElementById("sector-fields").style.display = isSector ? "" : "none"; } document.getElementById("f-antenna").addEventListener("change", toggleAntennaFields); document.getElementById("btn-add-ap").addEventListener("click", openAddModal); document.getElementById("btn-cancel").addEventListener("click", closeModal); document.getElementById("btn-delete-ap").addEventListener("click", () => deleteAP(editingId)); modalOverlay.addEventListener("click", e => { if (e.target === modalOverlay) closeModal(); }); form.addEventListener("submit", async (e) => { e.preventDefault(); const data = { name: document.getElementById("f-name").value.trim(), ssid: document.getElementById("f-ssid").value.trim(), lat: parseFloat(document.getElementById("f-lat").value), lon: parseFloat(document.getElementById("f-lon").value), frequency: parseFloat(document.getElementById("f-freq").value), channel: parseInt(document.getElementById("f-channel").value) || null, antenna_type: document.getElementById("f-antenna").value, azimuth: parseFloat(document.getElementById("f-azimuth").value) || 0, beamwidth: parseFloat(document.getElementById("f-beamwidth").value) || 360, coverage_radius: parseInt(document.getElementById("f-radius").value) || 2000, signal_strength: parseFloat(document.getElementById("f-signal").value) || -65, height: parseFloat(document.getElementById("f-height").value) || 30, notes: document.getElementById("f-notes").value.trim(), }; await saveAP(data); }); // ── Import dropdown toggle ───────────────────────────────────────────────── const importMenu = document.getElementById("import-menu"); document.getElementById("btn-import-toggle").addEventListener("click", (e) => { e.stopPropagation(); importMenu.classList.toggle("open"); }); document.addEventListener("click", () => importMenu.classList.remove("open")); // ══════════════════════════════════════════════════════════════════════════ // Google Sheets Import // ══════════════════════════════════════════════════════════════════════════ const sheetsOverlay = document.getElementById("sheets-overlay"); function openSheetsModal() { importMenu.classList.remove("open"); document.getElementById("sheets-url").value = ""; document.getElementById("sheets-sa-json").value = ""; document.getElementById("sa-section").style.display = "none"; document.getElementById("sheets-preview").style.display = "none"; document.getElementById("sheets-result").style.display = "none"; sheetsOverlay.classList.add("open"); } function closeSheetsModal() { sheetsOverlay.classList.remove("open"); } document.getElementById("btn-open-sheets").addEventListener("click", openSheetsModal); document.getElementById("sheets-cancel").addEventListener("click", closeSheetsModal); sheetsOverlay.addEventListener("click", e => { if (e.target === sheetsOverlay) closeSheetsModal(); }); document.getElementById("btn-show-sa").addEventListener("click", () => { const sec = document.getElementById("sa-section"); sec.style.display = sec.style.display === "none" ? "" : "none"; }); document.getElementById("btn-sheets-preview").addEventListener("click", async () => { const url = document.getElementById("sheets-url").value.trim(); if (!url) { alert("Please enter a Google Sheets URL"); return; } const btn = document.getElementById("btn-sheets-preview"); btn.innerHTML = 'Loading…'; btn.disabled = true; const previewDiv = document.getElementById("sheets-preview"); const resultDiv = document.getElementById("sheets-result"); resultDiv.style.display = "none"; try { const res = await fetch("/api/import/sheets/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const data = await res.json(); if (!res.ok || data.error) { previewDiv.style.display = "none"; resultDiv.className = "error-box"; resultDiv.style.display = "block"; resultDiv.textContent = data.error || "Preview failed"; } else { // Build table const headers = data.headers || []; const rows = data.sample_rows || []; const mapping = data.mapping || {}; let html = ``; headers.forEach(h => { const mapped = mapping[h]; html += ``; }); html += ``; rows.forEach(row => { html += ""; headers.forEach(h => { html += ``; }); html += ""; }); html += "
${h}${mapped ? " ✓" : ""}
${row[h] ?? ""}
"; document.getElementById("preview-table-wrap").innerHTML = html; const mappedCount = Object.values(mapping).filter(Boolean).length; document.getElementById("col-mapping").textContent = `${mappedCount} of ${headers.length} columns mapped. ` + (mapping["name"] && mapping["lat"] && mapping["lon"] && mapping["frequency"] ? "Required columns found ✓" : "⚠ Required columns missing: name, lat, lon, frequency"); previewDiv.style.display = ""; } } catch (err) { resultDiv.className = "error-box"; resultDiv.style.display = "block"; resultDiv.textContent = "Network error: " + err.message; } finally { btn.innerHTML = "Preview"; btn.disabled = false; } }); document.getElementById("btn-sheets-import").addEventListener("click", async () => { const url = document.getElementById("sheets-url").value.trim(); const credentials = document.getElementById("sheets-sa-json").value.trim() || null; if (!url) { alert("Please enter a Google Sheets URL"); return; } const btn = document.getElementById("btn-sheets-import"); btn.innerHTML = 'Importing…'; btn.disabled = true; const resultDiv = document.getElementById("sheets-result"); try { const res = await fetch("/api/import/sheets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, credentials }), }); const data = await res.json(); resultDiv.style.display = "block"; if (!res.ok || data.error) { resultDiv.className = "error-box"; resultDiv.textContent = data.error || "Import failed"; } else { resultDiv.className = "success-box"; let msg = `✓ Imported ${data.imported} access point${data.imported !== 1 ? "s" : ""}`; if (data.errors && data.errors.length) { msg += ` · ${data.errors.length} row${data.errors.length !== 1 ? "s" : ""} skipped`; } resultDiv.textContent = msg; await fetchAPs(); } } catch (err) { resultDiv.className = "error-box"; resultDiv.style.display = "block"; resultDiv.textContent = "Network error: " + err.message; } finally { btn.innerHTML = "Import All"; btn.disabled = false; } }); // ══════════════════════════════════════════════════════════════════════════ // SNMP Poll // ══════════════════════════════════════════════════════════════════════════ const snmpOverlay = document.getElementById("snmp-overlay"); function openSnmpModal() { importMenu.classList.remove("open"); document.getElementById("snmp-host").value = ""; document.getElementById("snmp-community").value = "public"; document.getElementById("snmp-port").value = "161"; document.getElementById("snmp-version").value = "2c"; document.getElementById("snmp-raw-section").style.display = "none"; document.getElementById("snmp-suggested").style.display = "none"; document.getElementById("snmp-error").style.display = "none"; document.getElementById("btn-snmp-add").style.display = "none"; snmpOverlay.classList.add("open"); } function closeSnmpModal() { snmpOverlay.classList.remove("open"); } document.getElementById("btn-open-snmp").addEventListener("click", openSnmpModal); document.getElementById("snmp-cancel").addEventListener("click", closeSnmpModal); snmpOverlay.addEventListener("click", e => { if (e.target === snmpOverlay) closeSnmpModal(); }); document.getElementById("btn-snmp-poll").addEventListener("click", async () => { const host = document.getElementById("snmp-host").value.trim(); if (!host) { alert("Please enter a host IP or hostname"); return; } const btn = document.getElementById("btn-snmp-poll"); btn.innerHTML = 'Polling…'; btn.disabled = true; const errorDiv = document.getElementById("snmp-error"); const rawSection = document.getElementById("snmp-raw-section"); const suggestedSection = document.getElementById("snmp-suggested"); errorDiv.style.display = "none"; rawSection.style.display = "none"; suggestedSection.style.display = "none"; document.getElementById("btn-snmp-add").style.display = "none"; try { const res = await fetch("/api/snmp/poll", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ host, community: document.getElementById("snmp-community").value.trim(), port: parseInt(document.getElementById("snmp-port").value), version: document.getElementById("snmp-version").value, }), }); const data = await res.json(); if (!res.ok || data.error) { errorDiv.style.display = "block"; errorDiv.textContent = data.error || "SNMP poll failed"; } else { // Raw data table const raw = data.raw || {}; const vendorColors = { ubiquiti: "#f6ad55", cambium_epmp: "#68d391", cambium_pmp450: "#4fd1c5", cambium_canopy: "#38b2ac", mikrotik: "#fc8181", unknown: "#a0aec0", }; const vColor = vendorColors[data.vendor] || "#a0aec0"; const vLabel = data.vendor_label || data.vendor; let tableHtml = ``; const friendlyKeys = { sysName: "System Name", sysDescr: "Description", sysLocation: "Location", sysContact: "Contact", sysObjectID: "Object ID", frequency: "Frequency (MHz)", channel_width: "Channel Width (MHz)", ssid: "SSID / Network Name", signal: "Signal (dBm)", noise: "Noise Floor (dBm)", tx_power: "TX Power (dBm)", connected_stations: "Connected Stations", color_code: "Color Code", mode: "Device Mode", dl_mcs: "DL MCS", ul_mcs: "UL MCS", }; Object.entries(raw).forEach(([k, v]) => { if (v !== null && v !== undefined && v !== "") { const label = friendlyKeys[k] || k; const display = k === "sysDescr" && v.length > 80 ? v.slice(0, 80) + "…" : v; tableHtml += ``; } }); // Show diagnostic errors (e.g. wrong community, OID not found) const errs = data.errors || {}; const errEntries = Object.entries(errs); if (errEntries.length) { tableHtml += ``; errEntries.forEach(([k, v]) => { tableHtml += ``; }); } tableHtml += "
Vendor / Platform${vLabel}
${label}${display}
SNMP errors (diagnostic)
${k}${v}
"; document.getElementById("snmp-raw-table").innerHTML = tableHtml; rawSection.style.display = ""; // Pre-fill suggested form const s = data.suggested || {}; document.getElementById("snmp-f-name").value = s.name || host; document.getElementById("snmp-f-ssid").value = s.ssid || ""; document.getElementById("snmp-f-lat").value = ""; document.getElementById("snmp-f-lon").value = ""; document.getElementById("snmp-f-freq").value = s.frequency || ""; document.getElementById("snmp-f-signal").value = s.signal_strength || ""; document.getElementById("snmp-f-notes").value = s.notes || ""; suggestedSection.style.display = ""; document.getElementById("btn-snmp-add").style.display = ""; } } catch (err) { errorDiv.style.display = "block"; errorDiv.textContent = "Network error: " + err.message; } finally { btn.innerHTML = "Poll Device"; btn.disabled = false; } }); document.getElementById("btn-snmp-add").addEventListener("click", async () => { const lat = parseFloat(document.getElementById("snmp-f-lat").value); const lon = parseFloat(document.getElementById("snmp-f-lon").value); if (isNaN(lat) || isNaN(lon) || (lat === 0 && lon === 0)) { alert("Please enter a valid Latitude and Longitude before adding to map."); document.getElementById("snmp-f-lat").focus(); return; } const apData = { name: document.getElementById("snmp-f-name").value.trim(), ssid: document.getElementById("snmp-f-ssid").value.trim(), lat, lon, frequency: parseFloat(document.getElementById("snmp-f-freq").value) || 2400, signal_strength: parseFloat(document.getElementById("snmp-f-signal").value) || -65, notes: document.getElementById("snmp-f-notes").value.trim(), antenna_type: "omni", coverage_radius: 2000, beamwidth: 360, azimuth: 0, height: 30, }; const res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(apData), }); if (res.ok) { closeSnmpModal(); await fetchAPs(); } else { const err = await res.json(); alert("Save failed: " + (err.error || "Unknown error")); } }); // ── Init ─────────────────────────────────────────────────────────────────── window.openEditModal = openEditModal; // expose for popup buttons fetchAPs();