Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ DOCSight can install catalog modules from **Settings > Extensions > Community Mo
| Module | Type | What it adds | Requirements |
|--------|------|--------------|--------------|
| [VF Kabel Deutschland Community Thresholds](thresholds-vfkd-community/) | `analysis` | Regional signal thresholds based on community recommendations for Vodafone/Kabel Deutschland cable connections | DOCSight 2026.2+ |
| [UDM WAN-Monitor](udm-wan-monitor/) | `integration` | WAN1/WAN2 status collection for Ubiquiti UDM Pro/SE setups, with DOCSight events and dashboard surfaces | DOCSight 2026.2+, UDM access on the local network |
| [UniFi WAN-Monitor](udm-wan-monitor/) | `integration` | WAN1/WAN2 status collection for Ubiquiti UDM Pro/SE and Cloud Gateway (UCG) setups, with DOCSight events and dashboard surfaces | DOCSight 2026.2+, UDM access on the local network |

The catalog is curated, but modules can be maintained either in this repository or in an external contributor repository. See [Submitting a Module](#submitting-a-module) for the registry format and review checklist.

Expand Down
6 changes: 3 additions & 3 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
},
{
"id": "community.udm_wan_monitor",
"name": "UDM WAN Monitor",
"description": "Monitor WAN1 and WAN2 on Ubiquiti UDM Pro/SE devices. Tracks link state, alive status, and failover events in the global DOCSight event log.",
"name": "UniFi WAN Monitor",
"description": "Monitor WAN1 and WAN2 on Ubiquiti UDM Pro/SE and Cloud Gateway (UCG) devices. Tracks link state, alive status, and failover events in the global DOCSight event log.",
"author": "Oggy512",
"repo": "https://github.com/itsDNNS/docsight-modules",
"version": "3.1.4",
"version": "3.3.0",
"min_app_version": "2026.2",
"type": "integration",
"verified": true,
Expand Down
2 changes: 1 addition & 1 deletion udm-wan-monitor/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# UDM WAN Monitor — DOCSight Community Module
# UniFi WAN Monitor — DOCSight Community Module
175 changes: 153 additions & 22 deletions udm-wan-monitor/collector.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""
UDM WAN Monitor — Collector v3.1.4
UniFi WAN Monitor — Collector v3.3.0

Polls /proxy/network/api/s/{site}/stat/device and extracts WAN status
from the UDM device entry.
from the UDM/UCG gateway device entry.

State tracked per interface:
- up (link state from wan1/wan2.up)
Expand All @@ -18,6 +18,7 @@
- Blueprint static_url_path removed (no static/ dir exists)
"""

import json
import logging
import re
import threading
Expand All @@ -38,6 +39,18 @@
}


def _result_ok(source: str, data) -> CollectorResult:
if hasattr(CollectorResult, "ok"):
return CollectorResult.ok(source, data)
return CollectorResult(source=source, data=data, success=True)


def _result_failure(source: str, error: str) -> CollectorResult:
if hasattr(CollectorResult, "failure"):
return CollectorResult.failure(source, error)
return CollectorResult(source=source, success=False, error=error)


class UdmWanCollector(Collector):
name = "udm_wan_monitor"

Expand All @@ -57,23 +70,27 @@ def is_enabled(self) -> bool:
def collect(self) -> CollectorResult:
cfg = _build_cfg_from(self._cfg)
if not cfg["host"]:
return CollectorResult.failure(self.name, "UDM host not configured")
return _result_failure(self.name, "UDM host not configured")
try:
session = self._get_session(cfg)
device = _fetch_udm_device(session, cfg)
device = _fetch_udm_device(
session, cfg, allow_no_wan=bool(parse_extra_ports(self._cfg))
)
except PermissionError:
self._invalidate_session()
return CollectorResult.failure(self.name, "Authentication failed")
return _result_failure(self.name, "Authentication failed")
except LookupError as exc:
return _result_failure(self.name, str(exc))
except requests.exceptions.ConnectionError:
self._invalidate_session()
return CollectorResult.failure(self.name, "Connection error")
return _result_failure(self.name, "Connection error")
except requests.exceptions.Timeout:
self._invalidate_session()
return CollectorResult.failure(self.name, "Timeout")
return _result_failure(self.name, "Timeout")
except Exception: # noqa: BLE001
self._invalidate_session()
logger.exception("UDM WAN collect failed")
return CollectorResult.failure(self.name, "Internal error")
return _result_failure(self.name, "Internal error")

parsed = parse_device(device)
events = self._detect_changes(parsed)
Expand All @@ -82,7 +99,7 @@ def collect(self) -> CollectorResult:

ts = utc_now()
self._last_result = {"parsed": parsed, "timestamp": ts}
return CollectorResult.ok(self.name, {"parsed": parsed, "events": events, "timestamp": ts})
return _result_ok(self.name, {"parsed": parsed, "events": events, "timestamp": ts})

# ── Session management ────────────────────────────────────────────────────

Expand All @@ -101,6 +118,12 @@ def _invalidate_session(self):
def _detect_changes(self, parsed: dict) -> list[dict]:
events: list[dict] = []
now = utc_now()
primary = primary_wan(self._cfg)

def sev(severity: str, key: str) -> str:
if primary and key != primary and severity == "critical":
return "warning"
return severity

with _state_lock:
# Failover: active uplink interface changed
Expand Down Expand Up @@ -155,7 +178,7 @@ def _detect_changes(self, parsed: dict) -> list[dict]:

if both_down:
msg = f"{label} ({ip or '?'}): down — alive=false, offline"
self._append_event(events, now, "critical", msg, label, "down", ip)
self._append_event(events, now, sev("critical", key), msg, label, "down", ip)
elif both_up:
msg = f"{label} ({ip or '?'}): restored — alive=true, online"
self._append_event(events, now, "info", msg, label, "up", ip)
Expand All @@ -165,7 +188,7 @@ def _detect_changes(self, parsed: dict) -> list[dict]:
msg = _event_msg(label, "alive", cur_alive, ip)
self._append_event(
events, now,
"critical" if degraded else "info",
sev("critical", key) if degraded else "info",
msg, label,
"alive_down" if degraded else "alive_up", ip,
)
Expand All @@ -174,7 +197,7 @@ def _detect_changes(self, parsed: dict) -> list[dict]:
msg = _event_msg(label, "online", cur_online, ip)
self._append_event(
events, now,
"critical" if degraded else "info",
sev("critical", key) if degraded else "info",
msg, label,
"offline" if degraded else "online", ip,
)
Expand All @@ -188,7 +211,7 @@ def _detect_changes(self, parsed: dict) -> list[dict]:
msg = _event_msg(label, "up", cur_up, ip)
self._append_event(
events, now,
"critical" if degraded else "info",
sev("critical", key) if degraded else "info",
msg, label,
"link_down" if degraded else "link_up", ip,
)
Expand Down Expand Up @@ -281,7 +304,7 @@ def _login(cfg: dict) -> requests.Session:
r = session.post(
f"{cfg['base']}/api/auth/login", json=payload, headers=headers, timeout=15
)
if r.status_code != 200:
if r.status_code in (404, 405):
r = session.post(
f"{cfg['base']}/api/login", json=payload, headers=headers, timeout=15
)
Expand All @@ -294,18 +317,125 @@ def _login(cfg: dict) -> requests.Session:
return session


def _fetch_udm_device(session: requests.Session, cfg: dict) -> dict:
GATEWAY_TYPES = ("udm", "ugw", "usg", "ucg")


def select_gateway(devices: list[dict]) -> dict | None:
for d in devices:
if d.get("type") in GATEWAY_TYPES and "wan1" in d:
return d
for d in devices:
if "wan1" in d:
return d
return next((d for d in devices if d.get("type") in GATEWAY_TYPES), None)


def primary_wan(cfg) -> str | None:
value = str(cfg.get("udm_wan_primary") or "").strip().lower()
return value if value in ("wan1", "wan2") else None


MAX_EXTRA_PORTS = 8
_IFNAME_RE = re.compile(r"[A-Za-z0-9_.:-]{1,32}")


def parse_extra_ports(cfg) -> list[dict]:
raw = cfg.get("udm_wan_extra_ports")
entries = []
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
entries = parsed if isinstance(parsed, list) else []
except ValueError:
entries = []
elif not raw:
for i in (1, 2):
entries.append({
"ifname": cfg.get(f"udm_wan_extra_port{i}_ifname"),
"alias": cfg.get(f"udm_wan_extra_port{i}_alias"),
})

ports, seen = [], set()
for e in entries:
if not isinstance(e, dict):
continue
ifname = str(e.get("ifname") or "").strip().lower()
if not _IFNAME_RE.fullmatch(ifname) or ifname in seen:
continue
seen.add(ifname)
alias = str(e.get("alias") or "").strip()[:40] or ifname
ports.append({"ifname": ifname, "alias": alias})
if len(ports) >= MAX_EXTRA_PORTS:
break
return ports


def has_wan(device: dict) -> bool:
return any(
(device.get(k) or {}).get("ifname") or (device.get(k) or {}).get("name")
for k in ("wan1", "wan2")
)


def _fetch_udm_device(session: requests.Session, cfg: dict, allow_no_wan: bool = False) -> dict:
"""Fetch /stat/device and return the UDM/gateway device entry."""
url = f"{cfg['base']}/proxy/network/api/s/{cfg['site']}/stat/device"
r = session.get(url, timeout=15)
if r.status_code == 401:
raise PermissionError("Session expired (401)")
r.raise_for_status()
devices = r.json().get("data", [])
return next(
(d for d in devices if d.get("type") in ("udm", "ugw", "usg")),
devices[0] if devices else {},
)
gateway = select_gateway(r.json().get("data", []))
if gateway is None:
raise LookupError("No gateway device found")
if not allow_no_wan and not has_wan(gateway):
raise LookupError(
"No WAN interface (wan1/wan2) found on gateway; "
"define the WAN port manually in the module settings"
)
return gateway


def build_wan_ports(device: dict, parsed: dict, extra_ports: list[dict]) -> list[dict]:
wanted = {ep["ifname"]: ep["alias"] for ep in extra_ports}
order = {}
for idx, key in enumerate(("wan1", "wan2")):
ifname = (parsed.get(key, {}).get("ifname") or "").lower()
if ifname:
wanted[ifname] = f"WAN {idx + 1}"
order[ifname] = idx

rows = []
for p in device.get("port_table", []):
ifname_raw = p.get("ifname") or ""
label = wanted.get(ifname_raw.lower())
if label is None:
continue
rows.append({
"label": label,
"name": p.get("name"),
"ifname": ifname_raw,
"up": p.get("up"),
"speed": p.get("speed"),
"full_duplex": p.get("full_duplex"),
"rx_bytes": p.get("rx_bytes"),
"tx_bytes": p.get("tx_bytes"),
"rx_bytes_r": p.get("rx_bytes-r"),
"tx_bytes_r": p.get("tx_bytes-r"),
"rx_errors": p.get("rx_errors"),
"tx_errors": p.get("tx_errors"),
"rx_dropped": p.get("rx_dropped"),
"tx_dropped": p.get("tx_dropped"),
})
rows.sort(key=lambda r: order.get(r["ifname"].lower(), 99))
return rows


def _global_ipv6(addresses) -> str | None:
for addr in addresses or []:
a = str(addr).strip().lower()
if a and not a.startswith(("fe8", "fe9", "fea", "feb")):
return str(addr).strip()
return None


def parse_device(d: dict) -> dict:
Expand Down Expand Up @@ -335,10 +465,11 @@ def _parse_wan(raw, lwi_key, lws_key, geo_key, uplink_data):
geo_entry = geo.get(geo_key, {})

return {
"ip": geo_entry.get("address") or raw.get("ip"),
"ip": raw.get("ip") or geo_entry.get("address"),
"ip_public": geo_entry.get("address"),
"ip_local": raw.get("ip"),
"netmask": raw.get("netmask"),
"ipv6": (raw.get("ipv6") or [None])[0],
"ipv6": _global_ipv6(raw.get("ipv6")),
"up": raw.get("up"),
"alive": lwi_entry.get("alive"),
"online": lws.get(lws_key) == "online",
Expand Down
40 changes: 27 additions & 13 deletions udm-wan-monitor/i18n/de.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"nav_title": "UDM WAN-Monitor",
"settings_title": "Ubiquiti UDM WAN-Monitor",
"settings_subtitle": "WAN1 & WAN2 überwachen · Events im globalen Log · UDM Pro / UDM SE",
"nav_title": "UniFi WAN-Monitor",
"settings_title": "UniFi WAN-Monitor",
"settings_subtitle": "WAN1 & WAN2 überwachen · Events im globalen Log · UDM / UCG",
"open_dashboard": "Dashboard",
"enabled": "UDM WAN-Monitor aktivieren",
"enabled": "UniFi WAN-Monitor aktivieren",
"enabled_hint": "Startet die Überwachung und schreibt WAN-Events in das globale DOCSight-Ereignislog.",
"host": "UDM Host / IP-Adresse",
"host_hint": "IP-Adresse oder Hostname der UDM Pro (HTTPS, Standard-Port 443)",
"host_hint": "IP-Adresse oder Hostname des Gateways (UDM/UCG) (HTTPS, Standard-Port 443)",
"username": "Benutzername",
"password": "Passwort",
"password_hint": "Wird verschlüsselt in der DOCSight-Konfiguration gespeichert.",
Expand All @@ -18,12 +18,8 @@
"verify_ssl": "SSL-Zertifikat prüfen",
"verify_ssl_hint": "Deaktivieren für selbst-signierte UDM-Zertifikate (empfohlen).",
"test_connection": "Verbindung testen",
"extra_ports_title": "Optionale zusätzliche Ports",
"extra_ports_hint": "Bis zu zwei weitere Ports überwachen (z.B. ein SFP+-Port als dritter WAN-Uplink). Interface-Name wie \"eth7\", Alias frei wählbar. Leer lassen um zu deaktivieren.",
"extra_port1_ifname": "Zusatz-Port 1 — Interface",
"extra_port1_alias": "Zusatz-Port 1 — Alias",
"extra_port2_ifname": "Zusatz-Port 2 — Interface",
"extra_port2_alias": "Zusatz-Port 2 — Alias",
"extra_ports_title": "WAN-Ports (manuell)",
"extra_ports_hint": "WAN 1 und WAN 2 werden automatisch erkannt. Meldet dein Gateway kein WAN, oder willst du einen weiteren Port wie einen SFP+-Uplink überwachen, füge ihn hier per Interface-Name hinzu (z.B. \"eth7\").",
"extra_port_ifname_hint": "Interface-Name, z.B. eth7",
"extra_port_alias_hint": "Anzeigename im Dashboard",
"event_down": "{iface} ({ip}): ausgefallen — alive=false, offline",
Expand All @@ -37,13 +33,14 @@
"event_failover": "WAN Failover: {old} → {new} ist jetzt aktiver Uplink",
"last_update": "Letztes Update",
"refresh": "Aktualisieren",
"device_subtitle": "Ubiquiti UDM Pro",
"device_subtitle": "Ubiquiti Gateway",
"label_link": "Link",
"label_alive": "Alive",
"label_wan": "WAN",
"label_active": "AKTIV",
"label_standby": "STANDBY",
"label_ip": "IP",
"label_ip_public": "Öffentliche IP",
"label_ipv6": "IPv6",
"label_dns": "DNS",
"label_isp": "ISP",
Expand Down Expand Up @@ -89,5 +86,22 @@
"pill_ok": "OK",
"pill_fail": "FAIL",
"pill_online": "Online",
"pill_offline": "Offline"
"pill_offline": "Offline",
"ports_empty": "Keine Ports definiert.",
"port_add": "Port hinzufügen",
"port_remove": "Entfernen",
"port_dialog_title": "WAN-Port hinzufügen",
"port_ifname": "Interface",
"port_alias": "Alias",
"port_confirm": "Hinzufügen",
"port_cancel": "Abbrechen",
"port_invalid": "Gültigen Interface-Namen eingeben, z.B. eth7.",
"port_duplicate": "Dieses Interface ist bereits in der Liste.",
"port_max": "Maximal 8 Ports.",
"primary_wan": "Kabelanschluss (relevant für DOCSight)",
"primary_wan_hint": "Der WAN, an dem dein Kabel-/DOCSIS-Anschluss hängt. Er wird im Dashboard hervorgehoben, und Ausfälle des anderen WAN werden nur als Warnung statt als kritisch gemeldet.",
"primary_wan_none": "Nicht festgelegt",
"primary_wan_wan1": "WAN 1",
"primary_wan_wan2": "WAN 2",
"badge_primary": "DOCSight"
}
Loading
Loading