Initial commit

This commit is contained in:
monoadmin
2026-04-10 15:36:34 -07:00
commit 82e3146ec5
9 changed files with 1916 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Flask environment (development | production)
FLASK_ENV=production

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Environment variables
.env
.env.*
!.env.example
# Dependencies
node_modules/
vendor/
# Build output
.next/
dist/
build/
*.pyc
__pycache__/
# OS
.DS_Store
Thumbs.db
# Logs
*.log

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

694
app.py Normal file
View File

@@ -0,0 +1,694 @@
import csv
import io
import json
import os
import re
import sqlite3
import requests
from flask import Flask, jsonify, request, render_template
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
DB_PATH = os.path.join("data", "wisp.db")
# ── Optional SNMP support ────────────────────────────────────────────────────
try:
import puresnmp
import puresnmp.exc
SNMP_AVAILABLE = True
except ImportError:
SNMP_AVAILABLE = False
# ── Optional gspread support (private Google Sheets) ────────────────────────
try:
import gspread
from google.oauth2.service_account import Credentials as SACredentials
GSPREAD_AVAILABLE = True
except ImportError:
GSPREAD_AVAILABLE = False
# ── Database ─────────────────────────────────────────────────────────────────
def get_db():
os.makedirs("data", exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS access_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
ssid TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
frequency REAL NOT NULL,
channel INTEGER,
antenna_type TEXT DEFAULT 'omni',
azimuth REAL DEFAULT 0,
beamwidth REAL DEFAULT 360,
coverage_radius INTEGER DEFAULT 2000,
signal_strength REAL DEFAULT -65,
height REAL DEFAULT 30,
notes TEXT DEFAULT ''
)
""")
count = conn.execute("SELECT COUNT(*) FROM access_points").fetchone()[0]
if count == 0:
demo = [
# Moscow Mountain — high-elevation hub serving the Palouse region
("Moscow Mountain", "Palouse-Net", 46.7950, -116.9600, 5800, 149, "omni", 0, 360, 8000, -58, 55, "High-elevation 5 GHz hub"),
# Downtown Moscow water tower — 2.4 GHz fill-in for dense urban core
("Downtown Moscow", "Palouse-Net", 46.7317, -117.0002, 2400, 6, "omni", 0, 360, 2500, -67, 30, "Urban 2.4 GHz fill-in"),
# East sector toward Pullman, WA
("East Sector - UI", "Palouse-Net", 46.7280, -116.9700, 5800, 157, "sector", 90, 120, 6000, -60, 40, "Sector toward Pullman / WSU"),
# South sector toward Troy, ID
("South Sector - Troy","Palouse-Net", 46.6900, -116.9950, 900,None, "sector", 180, 90, 10000, -63, 45, "900 MHz long-range toward Troy"),
# West sector toward Genesee, ID
("West Sector - Gen", "Palouse-Net", 46.7350, -117.0800, 5800, 161, "sector", 270, 120, 5000, -62, 38, "Sector toward Genesee"),
]
conn.executemany(
"INSERT INTO access_points (name,ssid,lat,lon,frequency,channel,antenna_type,azimuth,beamwidth,coverage_radius,signal_strength,height,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
demo,
)
conn.commit()
conn.close()
def row_to_dict(row):
return dict(row)
def insert_ap(conn, data):
cur = conn.execute(
"""INSERT INTO access_points
(name,ssid,lat,lon,frequency,channel,antenna_type,azimuth,beamwidth,coverage_radius,signal_strength,height,notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
data["name"], data.get("ssid", ""), float(data["lat"]), float(data["lon"]),
float(data["frequency"]), data.get("channel") or None,
data.get("antenna_type", "omni"),
float(data.get("azimuth", 0)), float(data.get("beamwidth", 360)),
int(data.get("coverage_radius", 2000)), float(data.get("signal_strength", -65)),
float(data.get("height", 30)), data.get("notes", ""),
),
)
return cur.lastrowid
# ── Core AP routes ────────────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/aps", methods=["GET"])
def get_aps():
conn = get_db()
rows = conn.execute("SELECT * FROM access_points ORDER BY name").fetchall()
conn.close()
return jsonify([row_to_dict(r) for r in rows])
@app.route("/api/aps", methods=["POST"])
def create_ap():
data = request.get_json()
if not all(k in data for k in ("name", "lat", "lon", "frequency")):
return jsonify({"error": "Missing required fields: name, lat, lon, frequency"}), 400
conn = get_db()
new_id = insert_ap(conn, data)
conn.commit()
row = conn.execute("SELECT * FROM access_points WHERE id=?", (new_id,)).fetchone()
conn.close()
return jsonify(row_to_dict(row)), 201
@app.route("/api/aps/<int:ap_id>", methods=["PUT"])
def update_ap(ap_id):
data = request.get_json()
conn = get_db()
existing = conn.execute("SELECT * FROM access_points WHERE id=?", (ap_id,)).fetchone()
if not existing:
conn.close()
return jsonify({"error": "Not found"}), 404
merged = {**row_to_dict(existing), **data, "id": ap_id}
conn.execute(
"""UPDATE access_points SET
name=?,ssid=?,lat=?,lon=?,frequency=?,channel=?,antenna_type=?,
azimuth=?,beamwidth=?,coverage_radius=?,signal_strength=?,height=?,notes=?
WHERE id=?""",
(
merged["name"], merged["ssid"], merged["lat"], merged["lon"],
merged["frequency"], merged["channel"], merged["antenna_type"],
merged["azimuth"], merged["beamwidth"], merged["coverage_radius"],
merged["signal_strength"], merged["height"], merged["notes"], ap_id,
),
)
conn.commit()
row = conn.execute("SELECT * FROM access_points WHERE id=?", (ap_id,)).fetchone()
conn.close()
return jsonify(row_to_dict(row))
@app.route("/api/aps/<int:ap_id>", methods=["DELETE"])
def delete_ap(ap_id):
conn = get_db()
result = conn.execute("DELETE FROM access_points WHERE id=?", (ap_id,))
conn.commit()
conn.close()
if result.rowcount == 0:
return jsonify({"error": "Not found"}), 404
return jsonify({"deleted": ap_id})
# ── Google Sheets import ──────────────────────────────────────────────────────
# Column name aliases (sheet header → internal field)
COLUMN_ALIASES = {
"name": "name", "tower": "name", "ap name": "name", "ap_name": "name",
"ssid": "ssid", "network": "ssid",
"lat": "lat", "latitude": "lat",
"lon": "lon", "lng": "lon", "longitude": "lon",
"frequency": "frequency", "freq": "frequency", "freq (mhz)": "frequency", "frequency (mhz)": "frequency",
"channel": "channel", "ch": "channel",
"antenna_type": "antenna_type", "antenna type": "antenna_type", "antenna": "antenna_type",
"azimuth": "azimuth", "bearing": "azimuth",
"beamwidth": "beamwidth", "beam_width": "beamwidth", "beam": "beamwidth",
"coverage_radius": "coverage_radius", "coverage radius": "coverage_radius",
"radius": "coverage_radius", "radius (m)": "coverage_radius",
"signal_strength": "signal_strength", "signal": "signal_strength",
"signal (dbm)": "signal_strength", "rssi": "signal_strength",
"height": "height", "tower height": "height", "height (m)": "height",
"notes": "notes", "comments": "notes", "description": "notes",
}
def _sheets_csv_url(url: str) -> str | None:
"""Convert any Google Sheets URL to a CSV export URL."""
m = re.search(r"/spreadsheets/d/([a-zA-Z0-9_-]+)", url)
if not m:
return None
sheet_id = m.group(1)
gid_m = re.search(r"[#&?]gid=(\d+)", url)
gid = gid_m.group(1) if gid_m else "0"
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv&gid={gid}"
def _normalise_row(raw: dict) -> dict:
"""Map sheet column names to internal field names (case-insensitive)."""
out = {}
for k, v in raw.items():
normalised = COLUMN_ALIASES.get(k.strip().lower())
if normalised and v.strip():
out[normalised] = v.strip()
return out
def _import_csv_rows(reader) -> tuple[list, list]:
imported, errors = [], []
conn = get_db()
for i, raw in enumerate(reader, start=2): # row 1 = header
row = _normalise_row(raw)
missing = [f for f in ("name", "lat", "lon", "frequency") if f not in row]
if missing:
errors.append({"row": i, "error": f"Missing columns: {', '.join(missing)}", "data": raw})
continue
try:
new_id = insert_ap(conn, row)
conn.commit()
imported.append({"id": new_id, "name": row["name"]})
except Exception as exc:
errors.append({"row": i, "error": str(exc), "data": raw})
conn.close()
return imported, errors
@app.route("/api/import/sheets", methods=["POST"])
def import_from_sheets():
"""Import APs from a Google Sheet.
Body JSON:
url Sheets URL (required for public sheets)
credentials service account JSON string (optional, for private sheets)
"""
data = request.get_json() or {}
url = data.get("url", "").strip()
credentials_json = data.get("credentials") # optional service-account JSON string
if not url:
return jsonify({"error": "url is required"}), 400
# ── Private sheet via service account ──
if credentials_json:
if not GSPREAD_AVAILABLE:
return jsonify({"error": "gspread is not installed"}), 500
try:
creds_dict = json.loads(credentials_json)
creds = SACredentials.from_service_account_info(
creds_dict,
scopes=["https://www.googleapis.com/auth/spreadsheets.readonly"],
)
gc = gspread.authorize(creds)
sh = gc.open_by_url(url)
# Use first worksheet
ws = sh.get_worksheet(0)
records = ws.get_all_records()
# Convert to csv.DictReader-like list of dicts
imported, errors = [], []
conn = get_db()
for i, raw in enumerate(records, start=2):
row = _normalise_row({k: str(v) for k, v in raw.items()})
missing = [f for f in ("name", "lat", "lon", "frequency") if f not in row]
if missing:
errors.append({"row": i, "error": f"Missing columns: {', '.join(missing)}", "data": raw})
continue
try:
new_id = insert_ap(conn, row)
conn.commit()
imported.append({"id": new_id, "name": row["name"]})
except Exception as exc:
errors.append({"row": i, "error": str(exc)})
conn.close()
return jsonify({"imported": len(imported), "errors": errors, "aps": imported})
except json.JSONDecodeError:
return jsonify({"error": "Invalid service account JSON"}), 400
except Exception as exc:
return jsonify({"error": str(exc)}), 400
# ── Public sheet via CSV export ──
csv_url = _sheets_csv_url(url)
if not csv_url:
return jsonify({"error": "Could not parse a Google Sheets ID from that URL"}), 400
try:
resp = requests.get(csv_url, timeout=15)
resp.raise_for_status()
except requests.RequestException as exc:
return jsonify({"error": f"Failed to fetch sheet: {exc}"}), 400
content = resp.text
# Google returns an HTML login page for private sheets
if "accounts.google.com" in content or "Sign in" in content[:500]:
return jsonify({
"error": "Sheet is private. Share it publicly ('Anyone with the link can view') or provide a service account JSON."
}), 403
reader = csv.DictReader(io.StringIO(content))
imported, errors = _import_csv_rows(reader)
return jsonify({"imported": len(imported), "errors": errors, "aps": imported})
@app.route("/api/import/sheets/preview", methods=["POST"])
def preview_sheets():
"""Return first 5 data rows + detected column mapping without importing."""
data = request.get_json() or {}
url = data.get("url", "").strip()
if not url:
return jsonify({"error": "url is required"}), 400
csv_url = _sheets_csv_url(url)
if not csv_url:
return jsonify({"error": "Could not parse a Google Sheets ID from that URL"}), 400
try:
resp = requests.get(csv_url, timeout=15)
resp.raise_for_status()
except requests.RequestException as exc:
return jsonify({"error": str(exc)}), 400
if "accounts.google.com" in resp.text or "Sign in" in resp.text[:500]:
return jsonify({"error": "Sheet is private. Share it publicly or provide credentials."}), 403
reader = csv.DictReader(io.StringIO(resp.text))
headers = reader.fieldnames or []
mapping = {h: COLUMN_ALIASES.get(h.strip().lower(), None) for h in headers}
rows = [dict(r) for r, _ in zip(reader, range(5))]
return jsonify({"headers": headers, "mapping": mapping, "sample_rows": rows})
# ── SNMP polling ──────────────────────────────────────────────────────────────
_STANDARD_OIDS = {
"sysDescr": "1.3.6.1.2.1.1.1.0",
"sysName": "1.3.6.1.2.1.1.5.0",
"sysLocation": "1.3.6.1.2.1.1.6.0",
"sysContact": "1.3.6.1.2.1.1.4.0",
"sysObjectID": "1.3.6.1.2.1.1.2.0", # critical for reliable vendor detection
}
# ── Ubiquiti ──────────────────────────────────────────────────────────────────
# Each key maps to an ordered list of OIDs to try; first non-None wins.
# This covers three hardware generations in one pass:
# idx .0 — AirOS 6.x / M-series (Rocket M2/M5, Bullet M, NanoStation M,
# NanoBridge M, PowerBeam M, AirGrid M …)
# idx .1 — AirOS 8.x / AC-series / XC platform (Rocket Prism 5AC Gen1/Gen2,
# LiteBeam AC, NanoBeam AC, PowerBeam AC, IsoStation 5AC …)
# .1.6 — airFiber backhaul (AF-5X, AF-5XHD, AF-24, AF-60-LR …)
_UBNT_OID_CHAINS = {
"frequency": [
"1.3.6.1.4.1.41112.1.4.1.1.3.0", # AirOS 6.x M-series
"1.3.6.1.4.1.41112.1.4.1.1.3.1", # AirOS 8.x AC-series / XC
"1.3.6.1.4.1.41112.1.6.1.1.3.0", # airFiber
],
"channel_width": [
"1.3.6.1.4.1.41112.1.4.1.1.4.0",
"1.3.6.1.4.1.41112.1.4.1.1.4.1",
"1.3.6.1.4.1.41112.1.6.1.1.4.0",
],
"tx_power": [
"1.3.6.1.4.1.41112.1.4.1.1.9.0",
"1.3.6.1.4.1.41112.1.4.1.1.9.1",
"1.3.6.1.4.1.41112.1.6.1.1.9.0",
],
"ssid": [
"1.3.6.1.4.1.41112.1.4.5.0", # AirMax SSID
"1.3.6.1.4.1.41112.1.6.4.0", # airFiber network name
],
"signal": [
"1.3.6.1.4.1.41112.1.4.7.1.5.1", # AirMax station 1 signal
"1.3.6.1.4.1.41112.1.4.7.1.5.2", # chain 1 fallback
"1.3.6.1.4.1.41112.1.6.7.1.3.1", # airFiber remote signal
],
"noise": [
"1.3.6.1.4.1.41112.1.4.7.1.6.1",
"1.3.6.1.4.1.41112.1.4.7.1.6.2",
],
"connected_stations": [
"1.3.6.1.4.1.41112.1.4.4.0", # AirMax registered clients
],
}
# ── Cambium ePMP ──────────────────────────────────────────────────────────────
# ePMP 1000 / 2000 / 3000 / 4500 / Force 180/200/300/425/4525 (MIB .22)
_EPMP_OIDS = {
"frequency": "1.3.6.1.4.1.17713.22.1.1.1.2.0",
"channel_width": "1.3.6.1.4.1.17713.22.1.1.1.6.0",
"tx_power": "1.3.6.1.4.1.17713.22.1.1.1.9.0",
"ssid": "1.3.6.1.4.1.17713.22.1.1.3.1.0",
"color_code": "1.3.6.1.4.1.17713.22.1.1.3.7.0",
"mode": "1.3.6.1.4.1.17713.22.1.1.1.5.0", # 1=AP 2=SM
"connected_stations": "1.3.6.1.4.1.17713.22.1.2.1.0",
"dl_mcs": "1.3.6.1.4.1.17713.22.1.1.5.2.0",
"ul_mcs": "1.3.6.1.4.1.17713.22.1.1.5.3.0",
}
# ── Cambium PMP 450 family ────────────────────────────────────────────────────
# PMP 450 / 450i / 450m / 450d / 450b / 450v (MIB .21)
_PMP450_OIDS = {
"frequency": "1.3.6.1.4.1.17713.21.1.1.18.0",
"channel_width": "1.3.6.1.4.1.17713.21.1.1.22.0",
"tx_power": "1.3.6.1.4.1.17713.21.1.1.27.0",
"ssid": "1.3.6.1.4.1.17713.21.1.1.3.0",
"signal": "1.3.6.1.4.1.17713.21.1.2.1.0",
"color_code": "1.3.6.1.4.1.17713.21.1.1.25.0",
"connected_stations": "1.3.6.1.4.1.17713.21.1.4.1.0",
}
# ── Cambium legacy Canopy / Motorola PMP 100 / PMP 400 / PMP 430 ─────────────
# (Enterprise OID 161 = Motorola; devices pre-date the Cambium rebrand)
_CANOPY_OIDS = {
"frequency": "1.3.6.1.4.1.161.19.89.1.1.5.0",
"ssid": "1.3.6.1.4.1.161.19.89.1.1.1.0",
"signal": "1.3.6.1.4.1.161.19.89.2.1.33.0",
"tx_power": "1.3.6.1.4.1.161.19.89.1.1.6.0",
}
# ── MikroTik ──────────────────────────────────────────────────────────────────
# RouterOS wireless (wAP, SXT, BaseBox, Groove, LHG, mANTBox, Audience…)
_MIKROTIK_OIDS = {
"frequency": "1.3.6.1.4.1.14988.1.1.1.7.0",
"channel_width": "1.3.6.1.4.1.14988.1.1.1.8.0",
"tx_power": "1.3.6.1.4.1.14988.1.1.1.3.0",
"ssid": "1.3.6.1.4.1.14988.1.1.1.9.0",
"signal": "1.3.6.1.4.1.14988.1.1.1.2.0", # overall RX signal
"noise": "1.3.6.1.4.1.14988.1.1.1.11.0",
"connected_stations": "1.3.6.1.4.1.14988.1.1.1.1.0",
}
# Human-readable display names for each vendor key
VENDOR_DISPLAY = {
"ubiquiti": "Ubiquiti (AirMax / airFiber)",
"cambium_epmp": "Cambium ePMP",
"cambium_pmp450": "Cambium PMP 450",
"cambium_canopy": "Cambium / Motorola Canopy",
"mikrotik": "MikroTik RouterOS",
"unknown": "Unknown",
}
def _snmp_decode(val) -> str | None:
"""Convert any puresnmp return type to a plain string.
puresnmp / x690 returns typed objects:
OctetString → bytes (sysDescr, sysName, SSID …)
Integer → int (frequency, signal …)
ObjectIdentifier → x690 OID object (sysObjectID)
TimeTicks → int
We need to handle all of these without raising.
"""
if val is None:
return None
# x690 ObjectIdentifier — convert to dotted-decimal string
type_name = type(val).__name__
if type_name == "ObjectIdentifier":
try:
# x690 stores the OID as a tuple in .value
if hasattr(val, "value"):
return "." + ".".join(str(n) for n in val.value)
# Fallback: str() on some versions gives dotted notation directly
s = str(val)
if s.startswith("(") or s.startswith("ObjectIdentifier"):
# Parse tuple repr like "(1, 3, 6, 1, 4, 1, 41112, …)"
nums = re.findall(r"\d+", s)
return "." + ".".join(nums) if nums else s
return s
except Exception:
return str(val)
if isinstance(val, bytes):
try:
return val.decode("utf-8", errors="replace").strip()
except Exception:
return repr(val)
# int, float, or anything else
return str(val).strip()
def _snmp_get(host: str, oid: str, community: str, port: int, version: int) -> tuple[str | None, str | None]:
"""Single SNMP GET. Returns (value_str, error_str)."""
try:
val = puresnmp.get(host, community, oid, port=port, version=version)
return _snmp_decode(val), None
except Exception as exc:
return None, str(exc)
def _snmp_get_chain(host: str, oids: list, community: str, port: int, version: int) -> str | None:
"""Try each OID in order; return the first non-None, non-empty result."""
for oid in oids:
val, _ = _snmp_get(host, oid, community, port, version)
if val and val not in ("0", "None"):
return val
return None
# Keywords checked against the combined sysDescr + sysName string
_UBNT_KEYWORDS = (
"ubiquiti", "ubnt", "airmax", "airos", "airfiber",
# AirMax AC / XC platform (AirOS 8.x)
"liteap", "lite ap", "lap-gps", "litap", # LiteAP / LiteAP GPS
"litebeam", "lite beam", # LiteBeam AC
"nanobeam", "nano beam", # NanoBeam AC
"nanostation", "nano station", # NanoStation AC / Loco AC
"nanobridge",
"powerbeam", "power beam", # PowerBeam AC
"isostation", "iso station", # IsoStation AC
"rocket prism", "rocketprism", "rp5ac", "rp-5ac",# Rocket Prism 5AC
"rocket m", "rocketm", # Rocket M (legacy)
"loco m", "locom", # NanoStation Loco M
"picostation",
"airstation",
"edgepoint",
"af-5", "af-24", "af-60", "airfiber", # airFiber
"edgeos", # EdgeRouter (not a radio but same vendor)
)
def _detect_vendor(descr: str, name: str, obj_id: str) -> str:
"""Determine vendor key from sysObjectID (reliable), then sysDescr + sysName keywords."""
o = (obj_id or "").lower()
# Combined text — AirOS puts the model in sysName on some firmware
combined = ((descr or "") + " " + (name or "")).lower()
# ── sysObjectID enterprise number (most reliable) ──────────────────────
if "41112" in o:
return "ubiquiti"
if "17713" in o:
# Distinguish ePMP (.22) from PMP 450 (.21) by the subtree number
after = o.split("17713")[-1]
if after.startswith(".22") or after.startswith("22"):
return "cambium_epmp"
return "cambium_pmp450"
if "161.19" in o or re.search(r"\.161\.1[^4]", o):
return "cambium_canopy"
if "14988" in o:
return "mikrotik"
# ── Keyword fallback ────────────────────────────────────────────────────
if any(k in combined for k in _UBNT_KEYWORDS):
return "ubiquiti"
if any(k in combined for k in (
"epmp", "force 1", "force1", "force 180", "force180",
"force 200", "force200", "force 300", "force300",
"force 400", "force400", "force 425", "force425",
"cnpilot", "ptp 550", "ptp550",
)):
return "cambium_epmp"
if any(k in combined for k in (
"pmp 450", "pmp450", "pmp 430", "pmp430",
"cambium pmp", "cambium networks pmp",
)):
return "cambium_pmp450"
if any(k in combined for k in ("canopy", "motorola pmp", "motorola bh")):
return "cambium_canopy"
if any(k in combined for k in ("cambium", "pmp")):
return "cambium_pmp450"
if any(k in combined for k in ("mikrotik", "routeros")):
return "mikrotik"
return "unknown"
# Probe OIDs tried (in order) when vendor is still unknown after sysObjectID + keywords.
# Uses well-known scalars from different UBNT MIB subtrees so at least one
# should respond on any AirMax / AirOS 8.x / airFiber device.
_UBNT_PROBE_OIDS = [
"1.3.6.1.4.1.41112.1.4.5.0", # AirMax SSID
"1.3.6.1.4.1.41112.1.4.1.1.3.0", # AirMax frequency (M-series)
"1.3.6.1.4.1.41112.1.4.1.1.3.1", # AirMax frequency (AC/XC, e.g. LiteAP GPS)
"1.3.6.1.4.1.41112.1.6.1.1.3.0", # airFiber frequency
]
def poll_device(host: str, community: str = "public", port: int = 161, version: int = 2) -> dict:
raw: dict[str, str | None] = {}
errors: dict[str, str] = {} # OID key → error message, for diagnostics
# Standard OIDs first (sysObjectID drives vendor detection)
for key, oid in _STANDARD_OIDS.items():
val, err = _snmp_get(host, oid, community, port, version)
raw[key] = val
if err and key in ("sysDescr", "sysName", "sysObjectID"):
errors[key] = err
vendor = _detect_vendor(
raw.get("sysDescr") or "",
raw.get("sysName") or "",
raw.get("sysObjectID") or "",
)
# If still unknown, probe several UBNT OIDs — handles AirOS devices whose
# sysDescr is bare "Linux <hostname>" and sysObjectID didn't decode cleanly.
if vendor == "unknown":
for probe_oid in _UBNT_PROBE_OIDS:
val, _ = _snmp_get(host, probe_oid, community, port, version)
if val and val not in ("0", "None"):
vendor = "ubiquiti"
break
# Fetch vendor-specific OIDs
if vendor == "ubiquiti":
for key, oids in _UBNT_OID_CHAINS.items():
raw[key] = _snmp_get_chain(host, oids, community, port, version)
elif vendor == "cambium_epmp":
for key, oid in _EPMP_OIDS.items():
raw[key], err = _snmp_get(host, oid, community, port, version)
if err:
errors[key] = err
elif vendor == "cambium_pmp450":
for key, oid in _PMP450_OIDS.items():
raw[key], err = _snmp_get(host, oid, community, port, version)
if err:
errors[key] = err
elif vendor == "cambium_canopy":
for key, oid in _CANOPY_OIDS.items():
raw[key], err = _snmp_get(host, oid, community, port, version)
if err:
errors[key] = err
elif vendor == "mikrotik":
for key, oid in _MIKROTIK_OIDS.items():
raw[key], err = _snmp_get(host, oid, community, port, version)
if err:
errors[key] = err
# Build pre-filled AP suggestion
suggested = {
"name": raw.get("sysName") or host,
"ssid": raw.get("ssid") or "",
"lat": 0.0,
"lon": 0.0,
"frequency": 0.0,
"signal_strength": -65.0,
"notes": f"SNMP import from {host} · {VENDOR_DISPLAY.get(vendor, vendor)}",
}
for field, key in (("frequency", "frequency"), ("signal_strength", "signal")):
v = raw.get(key)
if v is not None:
try:
suggested[field] = float(v)
except ValueError:
pass
# Normalise kHz → MHz (some firmware reports in kHz)
if suggested["frequency"] > 100_000:
suggested["frequency"] = round(suggested["frequency"] / 1000)
return {
"host": host,
"vendor": vendor,
"vendor_label": VENDOR_DISPLAY.get(vendor, vendor),
"raw": raw,
"suggested": suggested,
"snmp_available": True,
"errors": errors, # diagnostic — shown in UI if non-empty
}
@app.route("/api/snmp/poll", methods=["POST"])
def snmp_poll():
if not SNMP_AVAILABLE:
return jsonify({"error": "puresnmp is not installed"}), 501
data = request.get_json() or {}
host = data.get("host", "").strip()
if not host:
return jsonify({"error": "host is required"}), 400
community = data.get("community", "public").strip()
port = int(data.get("port", 161))
version_str = str(data.get("version", "2c")).strip()
version = 1 if version_str in ("1", "v1") else 2
try:
result = poll_device(host, community, port, version)
return jsonify(result)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@app.route("/api/snmp/status", methods=["GET"])
def snmp_status():
return jsonify({"available": SNMP_AVAILABLE, "gspread_available": GSPREAD_AVAILABLE})
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000, debug=False)

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
wisp-map:
build: .
ports:
- "5000:5000"
volumes:
- wisp-data:/app/data
environment:
- FLASK_ENV=production
restart: unless-stopped
volumes:
wisp-data:

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
flask==3.0.3
flask-cors==4.0.1
requests>=2.31.0
puresnmp>=2.0.0
gspread>=6.0.0
google-auth>=2.29.0

267
static/css/style.css Normal file
View File

@@ -0,0 +1,267 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f1117;
color: #e2e8f0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
header {
background: #1a1d2e;
border-bottom: 1px solid #2d3748;
padding: 0 1rem;
height: 52px;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
z-index: 1000;
}
header h1 { font-size: 1.1rem; font-weight: 700; color: #63b3ed; white-space: nowrap; }
header h1 span { color: #68d391; }
.header-filters { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.filter-btn {
padding: 3px 10px; border-radius: 999px; border: 1px solid transparent;
font-size: 0.78rem; cursor: pointer; font-weight: 600; transition: all .15s;
}
.filter-btn.active { opacity: 1; }
.filter-btn:not(.active) { opacity: 0.45; filter: grayscale(0.5); }
.filter-btn:hover { opacity: 0.9; }
.freq-900 { background: #553c9a; border-color: #6b46c1; color: #e9d8fd; }
.freq-2400 { background: #2a4365; border-color: #2b6cb0; color: #bee3f8; }
.freq-5800 { background: #22543d; border-color: #276749; color: #c6f6d5; }
.freq-6000 { background: #744210; border-color: #975a16; color: #fefcbf; }
.freq-other { background: #2d3748; border-color: #4a5568; color: #e2e8f0; }
/* ── Header action area ── */
.header-actions { display: flex; gap: 0.5rem; align-items: center; margin-left: auto; }
#btn-add-ap {
padding: 5px 14px; background: #3182ce; border: none;
border-radius: 6px; color: #fff; font-size: 0.82rem; font-weight: 600;
cursor: pointer; white-space: nowrap; transition: background .15s;
}
#btn-add-ap:hover { background: #2b6cb0; }
/* ── Dropdown ── */
.dropdown { position: relative; }
.btn-import {
padding: 5px 12px; background: #2d3748; border: 1px solid #4a5568;
border-radius: 6px; color: #e2e8f0; font-size: 0.82rem; font-weight: 600;
cursor: pointer; white-space: nowrap; transition: background .15s;
}
.btn-import:hover { background: #3d4a60; }
.dropdown-menu {
display: none; position: absolute; top: calc(100% + 4px); right: 0;
background: #1a1d2e; border: 1px solid #2d3748; border-radius: 8px;
min-width: 160px; z-index: 1100; overflow: hidden;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.dropdown-menu.open { display: block; }
.dropdown-item {
display: block; width: 100%; padding: 9px 14px; background: none; border: none;
text-align: left; color: #e2e8f0; font-size: 0.82rem; cursor: pointer;
transition: background .12s;
}
.dropdown-item:hover { background: #2d3748; }
/* ── Main layout ── */
.main { display: flex; flex: 1; overflow: hidden; }
/* ── Sidebar ── */
#sidebar {
width: 300px; flex-shrink: 0;
background: #1a1d2e; border-right: 1px solid #2d3748;
display: flex; flex-direction: column; overflow: hidden;
}
#sidebar-header {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid #2d3748;
font-size: 0.78rem; color: #a0aec0; font-weight: 600;
display: flex; justify-content: space-between; align-items: center;
}
#ap-count { font-weight: 700; color: #63b3ed; }
#ap-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.ap-item {
padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #1e2233;
transition: background .12s; display: flex; gap: 10px; align-items: center;
}
.ap-item:hover { background: #232640; }
.ap-item.selected { background: #2a3557; }
.ap-dot {
width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.25);
}
.ap-info { flex: 1; min-width: 0; }
.ap-name { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ap-meta { font-size: 0.72rem; color: #718096; margin-top: 1px; }
.ap-freq-badge {
font-size: 0.68rem; font-weight: 700; padding: 1px 6px;
border-radius: 4px; flex-shrink: 0;
}
/* ── Map ── */
#map { flex: 1; }
/* ── Legend ── */
#legend {
position: absolute; bottom: 28px; right: 10px; z-index: 900;
background: rgba(26,29,46,0.92); border: 1px solid #2d3748;
border-radius: 8px; padding: 10px 14px; font-size: 0.75rem;
min-width: 160px; backdrop-filter: blur(4px);
}
#legend h3 { font-size: 0.78rem; color: #a0aec0; margin-bottom: 8px; }
.legend-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.3); }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 2000;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: #1a1d2e; border: 1px solid #2d3748; border-radius: 12px;
padding: 1.5rem; width: 460px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
}
.modal h2 { font-size: 1rem; margin-bottom: 1rem; color: #63b3ed; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full { grid-column: 1/-1; }
.form-group label { font-size: 0.75rem; color: #a0aec0; font-weight: 600; }
.form-group input, .form-group select, .form-group textarea {
background: #0f1117; border: 1px solid #2d3748; border-radius: 6px;
color: #e2e8f0; padding: 6px 10px; font-size: 0.83rem;
transition: border-color .15s;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: #3182ce;
}
.form-group textarea { resize: vertical; min-height: 54px; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.btn-cancel {
padding: 6px 16px; background: transparent; border: 1px solid #4a5568;
border-radius: 6px; color: #a0aec0; cursor: pointer; font-size: 0.83rem;
}
.btn-cancel:hover { border-color: #718096; color: #e2e8f0; }
.btn-save {
padding: 6px 16px; background: #3182ce; border: none;
border-radius: 6px; color: #fff; cursor: pointer; font-size: 0.83rem; font-weight: 600;
}
.btn-save:hover { background: #2b6cb0; }
.btn-delete {
padding: 6px 16px; background: #c53030; border: none;
border-radius: 6px; color: #fff; cursor: pointer; font-size: 0.83rem; margin-right: auto;
}
.btn-delete:hover { background: #9b2c2c; }
/* ── Popup ── */
.leaflet-popup-content-wrapper {
background: #1a1d2e !important; border: 1px solid #2d3748 !important;
border-radius: 8px !important; color: #e2e8f0 !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important;
}
.leaflet-popup-tip { background: #1a1d2e !important; }
.popup-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 6px; color: #63b3ed; }
.popup-row { font-size: 0.78rem; display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.popup-label { color: #718096; }
.popup-value { font-weight: 600; }
.popup-edit-btn {
margin-top: 8px; width: 100%; padding: 5px; background: #2d3748; border: none;
border-radius: 5px; color: #e2e8f0; cursor: pointer; font-size: 0.78rem;
}
.popup-edit-btn:hover { background: #3d4a60; }
/* ── Signal bar ── */
.signal-bar-wrap { margin-top: 6px; }
.signal-label { font-size: 0.72rem; color: #718096; margin-bottom: 2px; }
.signal-bar { height: 6px; border-radius: 3px; background: #2d3748; overflow: hidden; }
.signal-fill { height: 100%; border-radius: 3px; }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2d3748; border-radius: 3px; }
/* ── Wider modal variant ── */
.modal-wide { width: 600px; }
/* ── Info / error boxes ── */
.info-box {
background: #1e2a3a; border: 1px solid #2b6cb0; border-radius: 6px;
padding: 10px 14px; font-size: 0.78rem; color: #bee3f8; line-height: 1.6;
}
.info-box code {
background: #2d3748; border-radius: 3px; padding: 1px 5px; font-size: 0.75rem; color: #90cdf4;
}
.error-box {
background: #2d1b1b; border: 1px solid #c53030; border-radius: 6px;
padding: 10px 14px; font-size: 0.82rem; color: #fc8181; margin-top: 0.5rem;
}
.success-box {
background: #1a2d1a; border: 1px solid #276749; border-radius: 6px;
padding: 10px 14px; font-size: 0.82rem; color: #68d391; margin-top: 0.5rem;
}
/* ── Section label ── */
.section-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: #718096; margin-bottom: 6px;
}
/* ── Preview table ── */
.preview-table {
width: 100%; border-collapse: collapse; font-size: 0.75rem;
}
.preview-table th {
background: #2d3748; padding: 5px 8px; text-align: left;
color: #a0aec0; font-weight: 600; border-bottom: 1px solid #4a5568;
white-space: nowrap;
}
.preview-table td {
padding: 4px 8px; border-bottom: 1px solid #1e2233; white-space: nowrap;
max-width: 160px; overflow: hidden; text-overflow: ellipsis; color: #cbd5e0;
}
.preview-table tr:hover td { background: #232640; }
.col-mapped { color: #68d391; }
.col-unmapped { color: #718096; }
/* ── SNMP raw table ── */
.snmp-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.snmp-table td { padding: 4px 8px; border-bottom: 1px solid #1e2233; }
.snmp-table td:first-child { color: #a0aec0; font-weight: 600; width: 38%; }
.snmp-table td:last-child { color: #e2e8f0; font-family: monospace; }
.snmp-table .vendor-row td { color: #f6ad55; }
/* ── Utility buttons ── */
.btn-secondary {
padding: 6px 16px; background: #2d3748; border: 1px solid #4a5568;
border-radius: 6px; color: #e2e8f0; cursor: pointer; font-size: 0.83rem;
}
.btn-secondary:hover { background: #3d4a60; }
.btn-link {
background: none; border: none; color: #4299e1; cursor: pointer;
font-size: 0.78rem; padding: 0; text-decoration: underline;
}
.btn-link:hover { color: #63b3ed; }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid #4a5568; border-top-color: #63b3ed;
border-radius: 50%; animation: spin 0.7s linear infinite;
vertical-align: middle; margin-right: 6px;
}

651
static/js/app.js Normal file
View 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();

248
templates/index.html Normal file
View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WISP Access Point Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="/static/css/style.css"/>
</head>
<body>
<header>
<h1>WISP <span>AP Map</span></h1>
<div class="header-filters">
<span style="font-size:0.75rem;color:#718096;margin-right:4px">Filter:</span>
<button class="filter-btn freq-900 active" data-freq="900">900 MHz</button>
<button class="filter-btn freq-2400 active" data-freq="2400">2.4 GHz</button>
<button class="filter-btn freq-5800 active" data-freq="5800">5 GHz</button>
<button class="filter-btn freq-6000 active" data-freq="6000">6 GHz</button>
<button class="filter-btn freq-other active" data-freq="other">Other</button>
</div>
<div class="header-actions">
<div class="dropdown">
<button class="btn-import" id="btn-import-toggle">Import ▾</button>
<div class="dropdown-menu" id="import-menu">
<button class="dropdown-item" id="btn-open-sheets">Google Sheets</button>
<button class="dropdown-item" id="btn-open-snmp">SNMP Poll</button>
</div>
</div>
<button id="btn-add-ap">+ Add AP</button>
</div>
</header>
<div class="main">
<div id="sidebar">
<div id="sidebar-header">
Access Points &nbsp;<span id="ap-count">0</span>
</div>
<div id="ap-list"></div>
</div>
<div id="map"></div>
</div>
<!-- Legend -->
<div id="legend">
<h3>Frequency Bands</h3>
<div class="legend-row"><div class="legend-dot" style="background:#805ad5"></div> 900 MHz</div>
<div class="legend-row"><div class="legend-dot" style="background:#4299e1"></div> 2.4 GHz</div>
<div class="legend-row"><div class="legend-dot" style="background:#48bb78"></div> 5 GHz</div>
<div class="legend-row"><div class="legend-dot" style="background:#f6ad55"></div> 6 GHz</div>
<div class="legend-row"><div class="legend-dot" style="background:#a0aec0"></div> Other</div>
<div style="margin-top:8px;border-top:1px solid #2d3748;padding-top:8px;font-size:0.72rem;color:#718096">
Click map to pre-fill location<br>when adding a new AP
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
Add / Edit AP Modal
═══════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<h2 id="modal-title">Add Access Point</h2>
<form id="ap-form">
<div class="form-grid">
<div class="form-group full">
<label>Tower / AP Name *</label>
<input id="f-name" type="text" required placeholder="e.g. Tower-Alpha">
</div>
<div class="form-group full">
<label>SSID</label>
<input id="f-ssid" type="text" placeholder="e.g. WISPNet">
</div>
<div class="form-group">
<label>Latitude *</label>
<input id="f-lat" type="number" step="any" required placeholder="46.7324">
</div>
<div class="form-group">
<label>Longitude *</label>
<input id="f-lon" type="number" step="any" required placeholder="-117.0002">
</div>
<div class="form-group">
<label>Frequency (MHz) *</label>
<input id="f-freq" type="number" step="any" required placeholder="5800">
</div>
<div class="form-group">
<label>Channel</label>
<input id="f-channel" type="number" placeholder="149">
</div>
<div class="form-group full">
<label>Antenna Type</label>
<select id="f-antenna">
<option value="omni">Omni-directional</option>
<option value="sector">Sector / Directional</option>
</select>
</div>
<div id="sector-fields" style="display:none;grid-column:1/-1">
<div class="form-grid">
<div class="form-group">
<label>Azimuth (°)</label>
<input id="f-azimuth" type="number" min="0" max="359" placeholder="180">
</div>
<div class="form-group">
<label>Beamwidth (°)</label>
<input id="f-beamwidth" type="number" min="1" max="360" placeholder="90">
</div>
</div>
</div>
<div class="form-group">
<label>Coverage Radius (m)</label>
<input id="f-radius" type="number" min="100" max="50000" placeholder="2000">
</div>
<div class="form-group">
<label>Signal Strength (dBm)</label>
<input id="f-signal" type="number" min="-120" max="-20" placeholder="-65">
</div>
<div class="form-group full">
<label>Tower Height (m)</label>
<input id="f-height" type="number" min="1" max="500" placeholder="30">
</div>
<div class="form-group full">
<label>Notes</label>
<textarea id="f-notes" placeholder="Optional notes..."></textarea>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-delete" id="btn-delete-ap" style="display:none">Delete</button>
<button type="button" class="btn-cancel" id="btn-cancel">Cancel</button>
<button type="submit" class="btn-save">Save</button>
</div>
</form>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
Google Sheets Import Modal
═══════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="sheets-overlay">
<div class="modal modal-wide">
<h2>Import from Google Sheets</h2>
<div class="info-box" style="margin-bottom:1rem">
<strong>Required sheet columns:</strong> <code>name</code>, <code>lat</code>, <code>lon</code>, <code>frequency</code> (MHz)<br>
<strong>Optional:</strong> <code>ssid</code>, <code>channel</code>, <code>antenna_type</code>, <code>azimuth</code>,
<code>beamwidth</code>, <code>coverage_radius</code>, <code>signal_strength</code>, <code>height</code>, <code>notes</code><br>
<strong>Public sheets:</strong> share as "Anyone with the link can view" — no credentials needed.
</div>
<div class="form-group" style="margin-bottom:0.8rem">
<label>Google Sheets URL *</label>
<input id="sheets-url" type="url" placeholder="https://docs.google.com/spreadsheets/d/…">
</div>
<div style="margin-bottom:0.5rem">
<button class="btn-link" id="btn-show-sa" type="button">+ Use service account (private sheet)</button>
</div>
<div id="sa-section" style="display:none;margin-bottom:0.8rem">
<div class="form-group">
<label>Service Account JSON (paste credentials)</label>
<textarea id="sheets-sa-json" rows="5" placeholder='{"type":"service_account","project_id":…}'></textarea>
</div>
</div>
<!-- Preview table -->
<div id="sheets-preview" style="display:none;margin-bottom:0.8rem">
<div class="section-label">Preview (first 5 rows)</div>
<div id="preview-table-wrap" style="overflow-x:auto"></div>
<div id="col-mapping" style="margin-top:0.5rem;font-size:0.75rem;color:#718096"></div>
</div>
<!-- Result -->
<div id="sheets-result" style="display:none"></div>
<div class="modal-actions">
<button type="button" class="btn-cancel" id="sheets-cancel">Cancel</button>
<button type="button" class="btn-secondary" id="btn-sheets-preview">Preview</button>
<button type="button" class="btn-save" id="btn-sheets-import">Import All</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
SNMP Poll Modal
═══════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="snmp-overlay">
<div class="modal modal-wide">
<h2>SNMP Device Poll</h2>
<div class="info-box" style="margin-bottom:1rem">
Polls a device via SNMPv1/v2c for system info, frequency, SSID, and signal strength.
Vendor auto-detection: <strong>Ubiquiti AirMax</strong>, <strong>MikroTik RouterOS</strong>, <strong>Cambium</strong>.
</div>
<div class="form-grid" style="margin-bottom:0.8rem">
<div class="form-group">
<label>Device IP / Hostname *</label>
<input id="snmp-host" type="text" placeholder="192.168.1.1">
</div>
<div class="form-group">
<label>Community String</label>
<input id="snmp-community" type="text" value="public" placeholder="public">
</div>
<div class="form-group">
<label>Port</label>
<input id="snmp-port" type="number" value="161" min="1" max="65535">
</div>
<div class="form-group">
<label>SNMP Version</label>
<select id="snmp-version">
<option value="2c" selected>v2c</option>
<option value="1">v1</option>
</select>
</div>
</div>
<!-- Raw results table -->
<div id="snmp-raw-section" style="display:none;margin-bottom:0.8rem">
<div class="section-label">Polled Data</div>
<div id="snmp-raw-table"></div>
</div>
<!-- Suggested AP pre-fill -->
<div id="snmp-suggested" style="display:none;margin-bottom:0.8rem">
<div class="section-label">Suggested AP values <span style="color:#718096;font-weight:400">(edit before saving)</span></div>
<div class="form-grid">
<div class="form-group"><label>Name</label><input id="snmp-f-name" type="text"></div>
<div class="form-group"><label>SSID</label><input id="snmp-f-ssid" type="text"></div>
<div class="form-group"><label>Latitude *</label><input id="snmp-f-lat" type="number" step="any" placeholder="Required"></div>
<div class="form-group"><label>Longitude *</label><input id="snmp-f-lon" type="number" step="any" placeholder="Required"></div>
<div class="form-group"><label>Frequency (MHz)</label><input id="snmp-f-freq" type="number" step="any"></div>
<div class="form-group"><label>Signal (dBm)</label><input id="snmp-f-signal" type="number" step="any"></div>
<div class="form-group full"><label>Notes</label><input id="snmp-f-notes" type="text"></div>
</div>
</div>
<div id="snmp-error" style="display:none" class="error-box"></div>
<div class="modal-actions">
<button type="button" class="btn-cancel" id="snmp-cancel">Cancel</button>
<button type="button" class="btn-secondary" id="btn-snmp-poll">Poll Device</button>
<button type="button" class="btn-save" id="btn-snmp-add" style="display:none">Add to Map</button>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>