From 3e0687c83e6ff62f600b98c0f532fbcb688a989c Mon Sep 17 00:00:00 2001 From: Lennard Indlekofer <10771600+lennard0711@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:08:16 +0200 Subject: [PATCH 1/3] feat(udm-wan-monitor): support UniFi Cloud Gateways (UCG), rename to UniFi WAN-Monitor, fix polling on current DOCSight Verified against a UCG Ultra (UDRULT, UniFi OS 5.1.33), including a real failover (WAN 1 unplugged and reconnected). - Derive WAN port names from the gateway's wan1/wan2 ifname instead of hardcoding the UDM Pro's eth9/eth8 (UCG Ultra: eth4/eth0), so it follows whichever ports are assigned as WAN, including reassigned LAN ports. Only wan1 and wan2 are considered; a WAN that is not configured produces no row. Dashboard cards show the real ifname. - Select the gateway by type (udm/ugw/usg/ucg) and presence of wan1; never fall back to devices[0], which could be a switch or AP and would report a bogus offline WAN. Fail with "No gateway device found" or "No WAN interface (wan1/wan2) found on gateway" instead. - Fix CollectorResult.ok()/failure() no longer existing in current DOCSight (every poll was recorded as a failure). Helpers use them only if present. - Reuse the collector's session for dashboard requests and only fall back to the legacy /api/login on 404/405. A login per request tripped UniFi OS' login rate limit (429), which was then masked as a 401. - Rename the module to "UniFi WAN-Monitor" (display name only; module id, directory, routes and event type are unchanged) and mention UCG in the texts. - Version 3.2.0. --- README.md | 2 +- registry.json | 6 +- udm-wan-monitor/__init__.py | 2 +- udm-wan-monitor/collector.py | 98 ++++++++++++++++--- udm-wan-monitor/i18n/de.json | 12 +-- udm-wan-monitor/i18n/en.json | 12 +-- udm-wan-monitor/i18n/es.json | 10 +- udm-wan-monitor/i18n/fr.json | 10 +- udm-wan-monitor/manifest.json | 6 +- udm-wan-monitor/routes.py | 52 ++++------ .../templates/udm_wan_settings.html | 12 +-- .../templates/udm_wan_standalone.html | 2 +- udm-wan-monitor/templates/udm_wan_tab.html | 11 ++- 13 files changed, 146 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 22443dd..deb4305 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/registry.json b/registry.json index 5077db2..2799fa3 100644 --- a/registry.json +++ b/registry.json @@ -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.2.0", "min_app_version": "2026.2", "type": "integration", "verified": true, diff --git a/udm-wan-monitor/__init__.py b/udm-wan-monitor/__init__.py index 655a2df..354f2d3 100644 --- a/udm-wan-monitor/__init__.py +++ b/udm-wan-monitor/__init__.py @@ -1 +1 @@ -# UDM WAN Monitor — DOCSight Community Module +# UniFi WAN Monitor — DOCSight Community Module diff --git a/udm-wan-monitor/collector.py b/udm-wan-monitor/collector.py index 0e4f86a..6c65400 100644 --- a/udm-wan-monitor/collector.py +++ b/udm-wan-monitor/collector.py @@ -1,8 +1,8 @@ """ -UDM WAN Monitor — Collector v3.1.4 +UniFi WAN Monitor — Collector v3.2.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) @@ -38,6 +38,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" @@ -57,23 +69,25 @@ 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) 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) @@ -82,7 +96,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 ──────────────────────────────────────────────────── @@ -281,7 +295,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 ) @@ -294,6 +308,26 @@ def _login(cfg: dict) -> requests.Session: return session +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 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) -> dict: """Fetch /stat/device and return the UDM/gateway device entry.""" url = f"{cfg['base']}/proxy/network/api/s/{cfg['site']}/stat/device" @@ -301,11 +335,47 @@ def _fetch_udm_device(session: requests.Session, cfg: dict) -> dict: 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 has_wan(gateway): + raise LookupError("No WAN interface (wan1/wan2) found on gateway") + 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 parse_device(d: dict) -> dict: diff --git a/udm-wan-monitor/i18n/de.json b/udm-wan-monitor/i18n/de.json index fad716c..70d4afa 100644 --- a/udm-wan-monitor/i18n/de.json +++ b/udm-wan-monitor/i18n/de.json @@ -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.", @@ -37,7 +37,7 @@ "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", diff --git a/udm-wan-monitor/i18n/en.json b/udm-wan-monitor/i18n/en.json index b901225..deb7495 100644 --- a/udm-wan-monitor/i18n/en.json +++ b/udm-wan-monitor/i18n/en.json @@ -1,12 +1,12 @@ { - "nav_title": "UDM WAN Monitor", - "settings_title": "Ubiquiti UDM WAN Monitor", - "settings_subtitle": "Monitor WAN1 & WAN2 · Events in global log · UDM Pro / UDM SE", + "nav_title": "UniFi WAN Monitor", + "settings_title": "UniFi WAN Monitor", + "settings_subtitle": "Monitor WAN1 & WAN2 · Events in global log · UDM / UCG", "open_dashboard": "Dashboard", - "enabled": "Enable UDM WAN Monitor", + "enabled": "Enable UniFi WAN Monitor", "enabled_hint": "Starts monitoring and writes WAN events into the global DOCSight event log.", "host": "UDM Host / IP Address", - "host_hint": "IP address or hostname of your UDM Pro (HTTPS, default port 443)", + "host_hint": "IP address or hostname of your gateway (UDM/UCG) (HTTPS, default port 443)", "username": "Username", "password": "Password", "password_hint": "Stored encrypted in the DOCSight configuration.", @@ -37,7 +37,7 @@ "event_failover": "WAN failover: {old} → {new} is now the active uplink", "last_update": "Last update", "refresh": "Refresh", - "device_subtitle": "Ubiquiti UDM Pro", + "device_subtitle": "Ubiquiti Gateway", "label_link": "Link", "label_alive": "Alive", "label_wan": "WAN", diff --git a/udm-wan-monitor/i18n/es.json b/udm-wan-monitor/i18n/es.json index b1c8e65..9f9c728 100644 --- a/udm-wan-monitor/i18n/es.json +++ b/udm-wan-monitor/i18n/es.json @@ -1,9 +1,9 @@ { - "nav_title": "Monitor WAN UDM", - "settings_title": "Monitor WAN Ubiquiti UDM", - "settings_subtitle": "Monitorizar WAN1 & WAN2 · Eventos en el log global · UDM Pro / UDM SE", + "nav_title": "Monitor WAN UniFi", + "settings_title": "Monitor WAN UniFi", + "settings_subtitle": "Monitorizar WAN1 & WAN2 · Eventos en el log global · UDM / UCG", "open_dashboard": "Panel", - "enabled": "Activar Monitor WAN UDM", + "enabled": "Activar Monitor WAN UniFi", "enabled_hint": "Inicia la monitorización y escribe eventos WAN en el registro global de DOCSight.", "host": "Host UDM / Dirección IP", "host_hint": "Dirección IP o nombre de host del UDM Pro (HTTPS, puerto 443 por defecto)", @@ -37,7 +37,7 @@ "event_failover": "Conmutación WAN: {old} → {new} es ahora el enlace activo", "last_update": "Última actualización", "refresh": "Actualizar", - "device_subtitle": "Ubiquiti UDM Pro", + "device_subtitle": "Ubiquiti Gateway", "label_link": "Enlace", "label_alive": "Alive", "label_wan": "WAN", diff --git a/udm-wan-monitor/i18n/fr.json b/udm-wan-monitor/i18n/fr.json index d072006..2d06a62 100644 --- a/udm-wan-monitor/i18n/fr.json +++ b/udm-wan-monitor/i18n/fr.json @@ -1,9 +1,9 @@ { - "nav_title": "Moniteur WAN UDM", - "settings_title": "Moniteur WAN Ubiquiti UDM", - "settings_subtitle": "Surveiller WAN1 & WAN2 · Événements dans le log global · UDM Pro / UDM SE", + "nav_title": "Moniteur WAN UniFi", + "settings_title": "Moniteur WAN UniFi", + "settings_subtitle": "Surveiller WAN1 & WAN2 · Événements dans le log global · UDM / UCG", "open_dashboard": "Tableau de bord", - "enabled": "Activer le moniteur WAN UDM", + "enabled": "Activer le moniteur WAN UniFi", "enabled_hint": "Démarre la surveillance et écrit les événements WAN dans le journal global DOCSight.", "host": "Hôte UDM / Adresse IP", "host_hint": "Adresse IP ou nom d'hôte de l'UDM Pro (HTTPS, port 443 par défaut)", @@ -37,7 +37,7 @@ "event_failover": "Basculement WAN : {old} → {new} est maintenant le lien actif", "last_update": "Dernière mise à jour", "refresh": "Actualiser", - "device_subtitle": "Ubiquiti UDM Pro", + "device_subtitle": "Ubiquiti Gateway", "label_link": "Lien", "label_alive": "Alive", "label_wan": "WAN", diff --git a/udm-wan-monitor/manifest.json b/udm-wan-monitor/manifest.json index 7f404da..c44bbc7 100644 --- a/udm-wan-monitor/manifest.json +++ b/udm-wan-monitor/manifest.json @@ -1,8 +1,8 @@ { "id": "community.udm_wan_monitor", - "name": "UDM WAN-Monitor", - "description": "Überwacht WAN1 und WAN2 einer Ubiquiti UDM Pro und erzeugt Events im globalen DOCSight-Log.", - "version": "3.1.4", + "name": "UniFi WAN-Monitor", + "description": "Überwacht WAN1 und WAN2 eines Ubiquiti UDM- oder UCG-Gateways und erzeugt Events im globalen DOCSight-Log.", + "version": "3.2.0", "author": "Oggy512", "minAppVersion": "2026.2", "type": "integration", diff --git a/udm-wan-monitor/routes.py b/udm-wan-monitor/routes.py index de7543a..31ea2a4 100644 --- a/udm-wan-monitor/routes.py +++ b/udm-wan-monitor/routes.py @@ -1,5 +1,5 @@ """ -UDM WAN Monitor — Flask Routes v3.1.4 +UniFi WAN Monitor — Flask Routes v3.2.0 GET /udm-wan → Standalone dashboard page GET /api/udm-wan/status → Latest cached data (JSON) @@ -81,9 +81,15 @@ def _build_cfg(): return d def _open_session(cfg): - _collector = _collector_mod() - _login = _collector._login - return _login(cfg) + c = _collector() + if c is not None and hasattr(c, "_get_session"): + return c._get_session(cfg) + return _collector_mod()._login(cfg) + +def _drop_session(): + c = _collector() + if c is not None and hasattr(c, "_invalidate_session"): + c._invalidate_session() # ── Pages ────────────────────────────────────────────────────────────────────── @@ -131,6 +137,11 @@ def api_detail(): return jsonify({"ok": False, "error": "Connection failed"}), 502 except req.exceptions.Timeout: return jsonify({"ok": False, "error": "Timeout"}), 504 + except LookupError as exc: + return jsonify({"ok": False, "error": str(exc)}), 404 + except PermissionError: + _drop_session() + return jsonify({"ok": False, "error": "Session expired, retry"}), 401 except Exception: # noqa: BLE001 logger.exception("UDM detail fetch failed") return jsonify({"ok": False, "error": "Internal error"}), 500 @@ -144,34 +155,7 @@ def api_detail(): if ifname: extra_ports_cfg.append({"ifname": ifname.lower(), "alias": alias or ifname}) - fixed = {"eth9": "WAN 1", "eth8": "WAN 2"} - extra = {ep["ifname"]: ep["alias"] for ep in extra_ports_cfg} - wanted = {**fixed, **extra} - order = {"eth9": 0, "eth8": 1} - - wan_ports = [] - for p in device.get("port_table", []): - ifname_raw = (p.get("ifname") or "") - ifname_lc = ifname_raw.lower() - if ifname_lc not in wanted: - continue - wan_ports.append({ - "label": wanted[ifname_lc], - "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"), - }) - wan_ports.sort(key=lambda p: order.get((p["ifname"] or "").lower(), 99)) + wan_ports = _collector.build_wan_ports(device, parsed, extra_ports_cfg) parsed["wan_ports"] = wan_ports return jsonify({ @@ -189,8 +173,8 @@ def api_test(): if not cfg["host"]: return jsonify({"ok": False, "error": "Host not configured"}), 400 try: - session = _open_session(cfg) _collector = _collector_mod() + session = _collector._login(cfg) _fetch_udm_device = _collector._fetch_udm_device parse_device = _collector.parse_device device = _fetch_udm_device(session, cfg) @@ -204,6 +188,8 @@ def api_test(): return jsonify({"ok": False, "error": "Connection failed"}), 502 except req.exceptions.Timeout: return jsonify({"ok": False, "error": "Timeout"}), 504 + except LookupError as exc: + return jsonify({"ok": False, "error": str(exc)}), 404 except Exception: # noqa: BLE001 logger.exception("UDM WAN test failed") return jsonify({"ok": False, "error": "Internal error"}), 500 diff --git a/udm-wan-monitor/templates/udm_wan_settings.html b/udm-wan-monitor/templates/udm_wan_settings.html index 761c730..19191fb 100644 --- a/udm-wan-monitor/templates/udm_wan_settings.html +++ b/udm-wan-monitor/templates/udm_wan_settings.html @@ -1,5 +1,5 @@ {# - UDM WAN Monitor — Settings Panel + UniFi WAN Monitor — Settings Panel Uses the exact DOCSight settings UI classes from settings.css: settings-panel, settings-card glass, card-header, card-title-group, card-icon, toggle-row, toggle, form-grid cols-2, form-field, @@ -7,7 +7,7 @@ #}
- {{ t.get('community.udm_wan_monitor.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.') }} + {{ t.get('community.udm_wan_monitor.extra_ports_hint', 'WAN 1 and WAN 2 are detected automatically. If your gateway reports no WAN, or you want to monitor another port such as an SFP+ uplink, add it here by interface name (e.g. "eth7").') }}
-