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