// ── 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 = `
`;
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 += `| ${h}${mapped ? " ✓" : ""} | `;
});
html += `
`;
rows.forEach(row => {
html += "";
headers.forEach(h => { html += `| ${row[h] ?? ""} | `; });
html += "
";
});
html += "
";
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 = `
| Vendor / Platform | ${vLabel} |
`;
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 += `| ${label} | ${display} |
`;
}
});
// Show diagnostic errors (e.g. wrong community, OID not found)
const errs = data.errors || {};
const errEntries = Object.entries(errs);
if (errEntries.length) {
tableHtml += `| SNMP errors (diagnostic) |
`;
errEntries.forEach(([k, v]) => {
tableHtml += `| ${k} | ${v} |
`;
});
}
tableHtml += "
";
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();