Initial commit
This commit is contained in:
651
static/js/app.js
Normal file
651
static/js/app.js
Normal file
@@ -0,0 +1,651 @@
|
||||
// ── 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: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
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 = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${size/2}" cy="${size/2}" r="${size/2 - border/2}"
|
||||
fill="${band.color}" stroke="white" stroke-width="${border}" opacity="0.95"/>
|
||||
${ap.antenna_type === "sector" ?
|
||||
`<line x1="${size/2}" y1="${size/2}" x2="${size/2}" y2="2" stroke="white" stroke-width="1.5" stroke-linecap="round"/>` :
|
||||
`<circle cx="${size/2}" cy="${size/2}" r="${size/4}" fill="white" opacity="0.5"/>`
|
||||
}
|
||||
</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 `
|
||||
<div style="min-width:190px">
|
||||
<div class="popup-title">${ap.name}</div>
|
||||
${ap.ssid ? `<div class="popup-row"><span class="popup-label">SSID</span><span class="popup-value">${ap.ssid}</span></div>` : ""}
|
||||
<div class="popup-row"><span class="popup-label">Frequency</span><span class="popup-value" style="color:${band.color}">${freqLabel(ap.frequency)}</span></div>
|
||||
${ap.channel ? `<div class="popup-row"><span class="popup-label">Channel</span><span class="popup-value">${ap.channel}</span></div>` : ""}
|
||||
<div class="popup-row"><span class="popup-label">Antenna</span><span class="popup-value">${ap.antenna_type === "sector" ? `Sector ${ap.azimuth}° / ${ap.beamwidth}°` : "Omni"}</span></div>
|
||||
<div class="popup-row"><span class="popup-label">Coverage</span><span class="popup-value">${(ap.coverage_radius/1000).toFixed(1)} km</span></div>
|
||||
<div class="popup-row"><span class="popup-label">Height</span><span class="popup-value">${ap.height} m</span></div>
|
||||
<div class="signal-bar-wrap">
|
||||
<div class="signal-label">Signal: ${ap.signal_strength} dBm (${q}%)</div>
|
||||
<div class="signal-bar"><div class="signal-fill" style="width:${q}%;background:${sColor}"></div></div>
|
||||
</div>
|
||||
${ap.notes ? `<div class="popup-row" style="margin-top:4px"><span class="popup-label">${ap.notes}</span></div>` : ""}
|
||||
<button class="popup-edit-btn" onclick="openEditModal(${ap.id})">Edit / Delete</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="ap-dot" style="background:${band.color}"></div>
|
||||
<div class="ap-info">
|
||||
<div class="ap-name">${ap.name}</div>
|
||||
<div class="ap-meta">${ap.lat.toFixed(4)}, ${ap.lon.toFixed(4)} · ${ap.coverage_radius >= 1000 ? (ap.coverage_radius/1000).toFixed(1)+"km" : ap.coverage_radius+"m"}</div>
|
||||
</div>
|
||||
<div class="ap-freq-badge" style="background:${band.bg};color:${band.color}">${freqLabel(ap.frequency)}</div>`;
|
||||
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 = '<span class="spinner"></span>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 = `<table class="preview-table"><thead><tr>`;
|
||||
headers.forEach(h => {
|
||||
const mapped = mapping[h];
|
||||
html += `<th class="${mapped ? "col-mapped" : "col-unmapped"}" title="${mapped ? "→ " + mapped : "not mapped"}">${h}${mapped ? " ✓" : ""}</th>`;
|
||||
});
|
||||
html += `</tr></thead><tbody>`;
|
||||
rows.forEach(row => {
|
||||
html += "<tr>";
|
||||
headers.forEach(h => { html += `<td>${row[h] ?? ""}</td>`; });
|
||||
html += "</tr>";
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
|
||||
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 = '<span class="spinner"></span>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 = '<span class="spinner"></span>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 = `<table class="snmp-table">
|
||||
<tr class="vendor-row"><td>Vendor / Platform</td><td style="color:${vColor}">${vLabel}</td></tr>`;
|
||||
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 += `<tr><td>${label}</td><td>${display}</td></tr>`;
|
||||
}
|
||||
});
|
||||
// Show diagnostic errors (e.g. wrong community, OID not found)
|
||||
const errs = data.errors || {};
|
||||
const errEntries = Object.entries(errs);
|
||||
if (errEntries.length) {
|
||||
tableHtml += `<tr><td colspan="2" style="padding-top:6px;color:#718096;font-size:0.72rem;font-weight:700">SNMP errors (diagnostic)</td></tr>`;
|
||||
errEntries.forEach(([k, v]) => {
|
||||
tableHtml += `<tr><td style="color:#fc8181">${k}</td><td style="color:#fc8181;font-family:monospace;font-size:0.72rem">${v}</td></tr>`;
|
||||
});
|
||||
}
|
||||
tableHtml += "</table>";
|
||||
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();
|
||||
Reference in New Issue
Block a user