commit 82e3146ec5a3ed28102434d7cc9c9e4cc5adc53a Author: monoadmin Date: Fri Apr 10 15:36:34 2026 -0700 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9adb409 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Flask environment (development | production) +FLASK_ENV=production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..487dd4c --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d32356 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..20945a7 --- /dev/null +++ b/app.py @@ -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/", 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/", 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 " 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8852bd5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5de5923 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..139adcc --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..7a6d788 --- /dev/null +++ b/static/js/app.js @@ -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: '© OpenStreetMap', + maxZoom: 19, +}).addTo(map); + +// Click map to pre-fill lat/lon in add form +map.on("click", (e) => { + mapClickLat = e.latlng.lat.toFixed(6); + mapClickLon = e.latlng.lng.toFixed(6); +}); + +// ── API helpers ──────────────────────────────────────────────────────────── +async function fetchAPs() { + const res = await fetch(API); + aps = await res.json(); + renderAll(); +} + +async function saveAP(data) { + const url = editingId ? `${API}/${editingId}` : API; + const method = editingId ? "PUT" : "POST"; + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json(); + alert("Error: " + (err.error || "Unknown error")); + return; + } + closeModal(); + await fetchAPs(); +} + +async function deleteAP(id) { + if (!confirm("Delete this access point?")) return; + await fetch(`${API}/${id}`, { method: "DELETE" }); + closeModal(); + selectedId = null; + await fetchAPs(); +} + +// ── Marker / Coverage drawing ────────────────────────────────────────────── +function clearMapObjects() { + Object.values(markers).forEach(m => map.removeLayer(m)); + Object.values(coverages).forEach(c => map.removeLayer(c)); + markers = {}; + coverages = {}; +} + +function makeIcon(ap) { + const band = bandForFreq(ap.frequency); + const size = selectedId === ap.id ? 22 : 16; + const border = selectedId === ap.id ? 3 : 2; + const svg = ` + + + ${ap.antenna_type === "sector" ? + `` : + `` + } + `; + return L.divIcon({ + html: svg, + className: "", + iconSize: [size, size], + iconAnchor: [size/2, size/2], + popupAnchor: [0, -size/2], + }); +} + +function drawCoverage(ap) { + const band = bandForFreq(ap.frequency); + const color = band.color; + const opacity = selectedId === ap.id ? 0.18 : 0.09; + const strokeOpacity = selectedId === ap.id ? 0.6 : 0.35; + + let layer; + if (ap.antenna_type === "sector" && ap.beamwidth < 355) { + layer = sectorPolygon(ap.lat, ap.lon, ap.coverage_radius, ap.azimuth, ap.beamwidth, color, opacity, strokeOpacity); + } else { + layer = L.circle([ap.lat, ap.lon], { + radius: ap.coverage_radius, + color, weight: 1.5, + fillColor: color, fillOpacity: opacity, opacity: strokeOpacity, + }); + } + layer.addTo(map); + layer.on("click", () => selectAP(ap.id)); + return layer; +} + +function sectorPolygon(lat, lon, radius, azimuth, beamwidth, color, fillOpacity, opacity) { + const points = [[lat, lon]]; + const startAngle = azimuth - beamwidth / 2; + const steps = Math.max(16, Math.round(beamwidth)); + for (let i = 0; i <= steps; i++) { + const angle = ((startAngle + (beamwidth * i) / steps) * Math.PI) / 180; + const dlat = (radius / 111320) * Math.cos(angle); + const dlon = (radius / (111320 * Math.cos((lat * Math.PI) / 180))) * Math.sin(angle); + points.push([lat + dlat, lon + dlon]); + } + points.push([lat, lon]); + return L.polygon(points, { + color, weight: 1.5, fillColor: color, fillOpacity, opacity, + }); +} + +function popupContent(ap) { + const band = bandForFreq(ap.frequency); + const q = signalQuality(ap.signal_strength); + const sColor = signalColor(q); + return ` +
+ + ${ap.ssid ? `` : ""} + + ${ap.channel ? `` : ""} + + + +
+
Signal: ${ap.signal_strength} dBm (${q}%)
+
+
+ ${ap.notes ? `` : ""} + +
`; +} + +function renderMapObjects() { + clearMapObjects(); + const visible = aps.filter(ap => activeFilters.has(bandForFreq(ap.frequency).key)); + + // Draw coverages first (below markers) + visible.forEach(ap => { + coverages[ap.id] = drawCoverage(ap); + }); + + // Draw markers + visible.forEach(ap => { + const marker = L.marker([ap.lat, ap.lon], { icon: makeIcon(ap) }); + marker.bindPopup(popupContent(ap), { maxWidth: 260 }); + marker.on("click", () => selectAP(ap.id)); + marker.addTo(map); + markers[ap.id] = marker; + }); +} + +// ── Sidebar ──────────────────────────────────────────────────────────────── +function renderSidebar() { + const list = document.getElementById("ap-list"); + const countEl = document.getElementById("ap-count"); + const visible = aps.filter(ap => activeFilters.has(bandForFreq(ap.frequency).key)); + countEl.textContent = visible.length; + list.innerHTML = ""; + + visible.forEach(ap => { + const band = bandForFreq(ap.frequency); + const item = document.createElement("div"); + item.className = "ap-item" + (ap.id === selectedId ? " selected" : ""); + item.dataset.id = ap.id; + item.innerHTML = ` +
+
+
${ap.name}
+
${ap.lat.toFixed(4)}, ${ap.lon.toFixed(4)} · ${ap.coverage_radius >= 1000 ? (ap.coverage_radius/1000).toFixed(1)+"km" : ap.coverage_radius+"m"}
+
+
${freqLabel(ap.frequency)}
`; + item.addEventListener("click", () => selectAP(ap.id)); + list.appendChild(item); + }); +} + +function renderAll() { + renderSidebar(); + renderMapObjects(); +} + +// ── Selection ────────────────────────────────────────────────────────────── +function selectAP(id) { + selectedId = id; + renderAll(); + const ap = aps.find(a => a.id === id); + if (ap && markers[id]) { + map.setView([ap.lat, ap.lon], Math.max(map.getZoom(), 14), { animate: true }); + markers[id].openPopup(); + } + // Scroll sidebar item into view + const el = document.querySelector(`.ap-item[data-id="${id}"]`); + if (el) el.scrollIntoView({ block: "nearest" }); +} + +// ── Filters ──────────────────────────────────────────────────────────────── +document.querySelectorAll(".filter-btn").forEach(btn => { + btn.addEventListener("click", () => { + const key = btn.dataset.freq; + if (activeFilters.has(key)) { + activeFilters.delete(key); + btn.classList.remove("active"); + } else { + activeFilters.add(key); + btn.classList.add("active"); + } + renderAll(); + }); +}); + +// ── Modal ────────────────────────────────────────────────────────────────── +const modalOverlay = document.getElementById("modal-overlay"); +const form = document.getElementById("ap-form"); + +function openAddModal() { + editingId = null; + document.getElementById("modal-title").textContent = "Add Access Point"; + document.getElementById("btn-delete-ap").style.display = "none"; + form.reset(); + if (mapClickLat) { + document.getElementById("f-lat").value = mapClickLat; + document.getElementById("f-lon").value = mapClickLon; + } + document.getElementById("f-beamwidth").value = 360; + document.getElementById("f-radius").value = 2000; + document.getElementById("f-signal").value = -65; + document.getElementById("f-height").value = 30; + document.getElementById("f-azimuth").value = 0; + toggleAntennaFields(); + modalOverlay.classList.add("open"); +} + +function openEditModal(id) { + editingId = id; + const ap = aps.find(a => a.id === id); + if (!ap) return; + document.getElementById("modal-title").textContent = "Edit Access Point"; + document.getElementById("btn-delete-ap").style.display = "block"; + document.getElementById("f-name").value = ap.name; + document.getElementById("f-ssid").value = ap.ssid || ""; + document.getElementById("f-lat").value = ap.lat; + document.getElementById("f-lon").value = ap.lon; + document.getElementById("f-freq").value = ap.frequency; + document.getElementById("f-channel").value = ap.channel || ""; + document.getElementById("f-antenna").value = ap.antenna_type; + document.getElementById("f-azimuth").value = ap.azimuth; + document.getElementById("f-beamwidth").value = ap.beamwidth; + document.getElementById("f-radius").value = ap.coverage_radius; + document.getElementById("f-signal").value = ap.signal_strength; + document.getElementById("f-height").value = ap.height; + document.getElementById("f-notes").value = ap.notes || ""; + toggleAntennaFields(); + modalOverlay.classList.add("open"); +} + +function closeModal() { + modalOverlay.classList.remove("open"); + editingId = null; +} + +function toggleAntennaFields() { + const isSector = document.getElementById("f-antenna").value === "sector"; + document.getElementById("sector-fields").style.display = isSector ? "" : "none"; +} + +document.getElementById("f-antenna").addEventListener("change", toggleAntennaFields); +document.getElementById("btn-add-ap").addEventListener("click", openAddModal); +document.getElementById("btn-cancel").addEventListener("click", closeModal); +document.getElementById("btn-delete-ap").addEventListener("click", () => deleteAP(editingId)); +modalOverlay.addEventListener("click", e => { if (e.target === modalOverlay) closeModal(); }); + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + const data = { + name: document.getElementById("f-name").value.trim(), + ssid: document.getElementById("f-ssid").value.trim(), + lat: parseFloat(document.getElementById("f-lat").value), + lon: parseFloat(document.getElementById("f-lon").value), + frequency: parseFloat(document.getElementById("f-freq").value), + channel: parseInt(document.getElementById("f-channel").value) || null, + antenna_type: document.getElementById("f-antenna").value, + azimuth: parseFloat(document.getElementById("f-azimuth").value) || 0, + beamwidth: parseFloat(document.getElementById("f-beamwidth").value) || 360, + coverage_radius: parseInt(document.getElementById("f-radius").value) || 2000, + signal_strength: parseFloat(document.getElementById("f-signal").value) || -65, + height: parseFloat(document.getElementById("f-height").value) || 30, + notes: document.getElementById("f-notes").value.trim(), + }; + await saveAP(data); +}); + +// ── Import dropdown toggle ───────────────────────────────────────────────── +const importMenu = document.getElementById("import-menu"); +document.getElementById("btn-import-toggle").addEventListener("click", (e) => { + e.stopPropagation(); + importMenu.classList.toggle("open"); +}); +document.addEventListener("click", () => importMenu.classList.remove("open")); + +// ══════════════════════════════════════════════════════════════════════════ +// Google Sheets Import +// ══════════════════════════════════════════════════════════════════════════ +const sheetsOverlay = document.getElementById("sheets-overlay"); + +function openSheetsModal() { + importMenu.classList.remove("open"); + document.getElementById("sheets-url").value = ""; + document.getElementById("sheets-sa-json").value = ""; + document.getElementById("sa-section").style.display = "none"; + document.getElementById("sheets-preview").style.display = "none"; + document.getElementById("sheets-result").style.display = "none"; + sheetsOverlay.classList.add("open"); +} + +function closeSheetsModal() { + sheetsOverlay.classList.remove("open"); +} + +document.getElementById("btn-open-sheets").addEventListener("click", openSheetsModal); +document.getElementById("sheets-cancel").addEventListener("click", closeSheetsModal); +sheetsOverlay.addEventListener("click", e => { if (e.target === sheetsOverlay) closeSheetsModal(); }); + +document.getElementById("btn-show-sa").addEventListener("click", () => { + const sec = document.getElementById("sa-section"); + sec.style.display = sec.style.display === "none" ? "" : "none"; +}); + +document.getElementById("btn-sheets-preview").addEventListener("click", async () => { + const url = document.getElementById("sheets-url").value.trim(); + if (!url) { alert("Please enter a Google Sheets URL"); return; } + + const btn = document.getElementById("btn-sheets-preview"); + btn.innerHTML = 'Loading…'; + btn.disabled = true; + + const previewDiv = document.getElementById("sheets-preview"); + const resultDiv = document.getElementById("sheets-result"); + resultDiv.style.display = "none"; + + try { + const res = await fetch("/api/import/sheets/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + const data = await res.json(); + + if (!res.ok || data.error) { + previewDiv.style.display = "none"; + resultDiv.className = "error-box"; + resultDiv.style.display = "block"; + resultDiv.textContent = data.error || "Preview failed"; + } else { + // Build table + const headers = data.headers || []; + const rows = data.sample_rows || []; + const mapping = data.mapping || {}; + + let html = ``; + headers.forEach(h => { + const mapped = mapping[h]; + html += ``; + }); + html += ``; + rows.forEach(row => { + html += ""; + headers.forEach(h => { html += ``; }); + html += ""; + }); + html += "
${h}${mapped ? " ✓" : ""}
${row[h] ?? ""}
"; + + document.getElementById("preview-table-wrap").innerHTML = html; + + const mappedCount = Object.values(mapping).filter(Boolean).length; + document.getElementById("col-mapping").textContent = + `${mappedCount} of ${headers.length} columns mapped. ` + + (mapping["name"] && mapping["lat"] && mapping["lon"] && mapping["frequency"] + ? "Required columns found ✓" + : "⚠ Required columns missing: name, lat, lon, frequency"); + + previewDiv.style.display = ""; + } + } catch (err) { + resultDiv.className = "error-box"; + resultDiv.style.display = "block"; + resultDiv.textContent = "Network error: " + err.message; + } finally { + btn.innerHTML = "Preview"; + btn.disabled = false; + } +}); + +document.getElementById("btn-sheets-import").addEventListener("click", async () => { + const url = document.getElementById("sheets-url").value.trim(); + const credentials = document.getElementById("sheets-sa-json").value.trim() || null; + if (!url) { alert("Please enter a Google Sheets URL"); return; } + + const btn = document.getElementById("btn-sheets-import"); + btn.innerHTML = 'Importing…'; + btn.disabled = true; + + const resultDiv = document.getElementById("sheets-result"); + + try { + const res = await fetch("/api/import/sheets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, credentials }), + }); + const data = await res.json(); + + resultDiv.style.display = "block"; + if (!res.ok || data.error) { + resultDiv.className = "error-box"; + resultDiv.textContent = data.error || "Import failed"; + } else { + resultDiv.className = "success-box"; + let msg = `✓ Imported ${data.imported} access point${data.imported !== 1 ? "s" : ""}`; + if (data.errors && data.errors.length) { + msg += ` · ${data.errors.length} row${data.errors.length !== 1 ? "s" : ""} skipped`; + } + resultDiv.textContent = msg; + await fetchAPs(); + } + } catch (err) { + resultDiv.className = "error-box"; + resultDiv.style.display = "block"; + resultDiv.textContent = "Network error: " + err.message; + } finally { + btn.innerHTML = "Import All"; + btn.disabled = false; + } +}); + +// ══════════════════════════════════════════════════════════════════════════ +// SNMP Poll +// ══════════════════════════════════════════════════════════════════════════ +const snmpOverlay = document.getElementById("snmp-overlay"); + +function openSnmpModal() { + importMenu.classList.remove("open"); + document.getElementById("snmp-host").value = ""; + document.getElementById("snmp-community").value = "public"; + document.getElementById("snmp-port").value = "161"; + document.getElementById("snmp-version").value = "2c"; + document.getElementById("snmp-raw-section").style.display = "none"; + document.getElementById("snmp-suggested").style.display = "none"; + document.getElementById("snmp-error").style.display = "none"; + document.getElementById("btn-snmp-add").style.display = "none"; + snmpOverlay.classList.add("open"); +} + +function closeSnmpModal() { + snmpOverlay.classList.remove("open"); +} + +document.getElementById("btn-open-snmp").addEventListener("click", openSnmpModal); +document.getElementById("snmp-cancel").addEventListener("click", closeSnmpModal); +snmpOverlay.addEventListener("click", e => { if (e.target === snmpOverlay) closeSnmpModal(); }); + +document.getElementById("btn-snmp-poll").addEventListener("click", async () => { + const host = document.getElementById("snmp-host").value.trim(); + if (!host) { alert("Please enter a host IP or hostname"); return; } + + const btn = document.getElementById("btn-snmp-poll"); + btn.innerHTML = 'Polling…'; + btn.disabled = true; + + const errorDiv = document.getElementById("snmp-error"); + const rawSection = document.getElementById("snmp-raw-section"); + const suggestedSection = document.getElementById("snmp-suggested"); + errorDiv.style.display = "none"; + rawSection.style.display = "none"; + suggestedSection.style.display = "none"; + document.getElementById("btn-snmp-add").style.display = "none"; + + try { + const res = await fetch("/api/snmp/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + host, + community: document.getElementById("snmp-community").value.trim(), + port: parseInt(document.getElementById("snmp-port").value), + version: document.getElementById("snmp-version").value, + }), + }); + const data = await res.json(); + + if (!res.ok || data.error) { + errorDiv.style.display = "block"; + errorDiv.textContent = data.error || "SNMP poll failed"; + } else { + // Raw data table + const raw = data.raw || {}; + const vendorColors = { + ubiquiti: "#f6ad55", + cambium_epmp: "#68d391", + cambium_pmp450: "#4fd1c5", + cambium_canopy: "#38b2ac", + mikrotik: "#fc8181", + unknown: "#a0aec0", + }; + const vColor = vendorColors[data.vendor] || "#a0aec0"; + const vLabel = data.vendor_label || data.vendor; + + let tableHtml = ` + `; + const friendlyKeys = { + sysName: "System Name", + sysDescr: "Description", + sysLocation: "Location", + sysContact: "Contact", + sysObjectID: "Object ID", + frequency: "Frequency (MHz)", + channel_width: "Channel Width (MHz)", + ssid: "SSID / Network Name", + signal: "Signal (dBm)", + noise: "Noise Floor (dBm)", + tx_power: "TX Power (dBm)", + connected_stations: "Connected Stations", + color_code: "Color Code", + mode: "Device Mode", + dl_mcs: "DL MCS", + ul_mcs: "UL MCS", + }; + Object.entries(raw).forEach(([k, v]) => { + if (v !== null && v !== undefined && v !== "") { + const label = friendlyKeys[k] || k; + const display = k === "sysDescr" && v.length > 80 ? v.slice(0, 80) + "…" : v; + tableHtml += ``; + } + }); + // Show diagnostic errors (e.g. wrong community, OID not found) + const errs = data.errors || {}; + const errEntries = Object.entries(errs); + if (errEntries.length) { + tableHtml += ``; + errEntries.forEach(([k, v]) => { + tableHtml += ``; + }); + } + tableHtml += "
Vendor / Platform${vLabel}
${label}${display}
SNMP errors (diagnostic)
${k}${v}
"; + document.getElementById("snmp-raw-table").innerHTML = tableHtml; + rawSection.style.display = ""; + + // Pre-fill suggested form + const s = data.suggested || {}; + document.getElementById("snmp-f-name").value = s.name || host; + document.getElementById("snmp-f-ssid").value = s.ssid || ""; + document.getElementById("snmp-f-lat").value = ""; + document.getElementById("snmp-f-lon").value = ""; + document.getElementById("snmp-f-freq").value = s.frequency || ""; + document.getElementById("snmp-f-signal").value = s.signal_strength || ""; + document.getElementById("snmp-f-notes").value = s.notes || ""; + suggestedSection.style.display = ""; + document.getElementById("btn-snmp-add").style.display = ""; + } + } catch (err) { + errorDiv.style.display = "block"; + errorDiv.textContent = "Network error: " + err.message; + } finally { + btn.innerHTML = "Poll Device"; + btn.disabled = false; + } +}); + +document.getElementById("btn-snmp-add").addEventListener("click", async () => { + const lat = parseFloat(document.getElementById("snmp-f-lat").value); + const lon = parseFloat(document.getElementById("snmp-f-lon").value); + if (isNaN(lat) || isNaN(lon) || (lat === 0 && lon === 0)) { + alert("Please enter a valid Latitude and Longitude before adding to map."); + document.getElementById("snmp-f-lat").focus(); + return; + } + + const apData = { + name: document.getElementById("snmp-f-name").value.trim(), + ssid: document.getElementById("snmp-f-ssid").value.trim(), + lat, lon, + frequency: parseFloat(document.getElementById("snmp-f-freq").value) || 2400, + signal_strength: parseFloat(document.getElementById("snmp-f-signal").value) || -65, + notes: document.getElementById("snmp-f-notes").value.trim(), + antenna_type: "omni", coverage_radius: 2000, beamwidth: 360, azimuth: 0, height: 30, + }; + + const res = await fetch(API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(apData), + }); + if (res.ok) { + closeSnmpModal(); + await fetchAPs(); + } else { + const err = await res.json(); + alert("Save failed: " + (err.error || "Unknown error")); + } +}); + +// ── Init ─────────────────────────────────────────────────────────────────── +window.openEditModal = openEditModal; // expose for popup buttons +fetchAPs(); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..137b670 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,248 @@ + + + + + + WISP Access Point Map + + + + + +
+

WISP AP Map

+
+ Filter: + + + + + +
+
+ + +
+
+ +
+ +
+
+ + +
+

Frequency Bands

+
900 MHz
+
2.4 GHz
+
5 GHz
+
6 GHz
+
Other
+
+ Click map to pre-fill location
when adding a new AP +
+
+ + + + + + + + + + + + + +