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..0d48153 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.3.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..93a9b07 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.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) @@ -18,6 +18,7 @@ - Blueprint static_url_path removed (no static/ dir exists) """ +import json import logging import re import threading @@ -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" @@ -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) @@ -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 ──────────────────────────────────────────────────── @@ -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 @@ -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) @@ -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, ) @@ -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, ) @@ -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, ) @@ -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 ) @@ -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: @@ -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", diff --git a/udm-wan-monitor/i18n/de.json b/udm-wan-monitor/i18n/de.json index fad716c..a0a8db5 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.", @@ -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", @@ -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", @@ -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" } diff --git a/udm-wan-monitor/i18n/en.json b/udm-wan-monitor/i18n/en.json index b901225..6b7a0e8 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.", @@ -18,12 +18,8 @@ "verify_ssl": "Verify SSL Certificate", "verify_ssl_hint": "Disable for self-signed UDM certificates (recommended).", "test_connection": "Test Connection", - "extra_ports_title": "Optional Additional Ports", - "extra_ports_hint": "Monitor up to two extra ports (e.g. an SFP+ port as a third WAN uplink). Enter the interface name like \"eth7\" and a free-form alias. Leave empty to disable.", - "extra_port1_ifname": "Extra Port 1 — Interface", - "extra_port1_alias": "Extra Port 1 — Alias", - "extra_port2_ifname": "Extra Port 2 — Interface", - "extra_port2_alias": "Extra Port 2 — Alias", + "extra_ports_title": "WAN ports (manual)", + "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\").", "extra_port_ifname_hint": "Interface name, e.g. eth7", "extra_port_alias_hint": "Display name in dashboard", "event_down": "{iface} ({ip}): down — alive=false, offline", @@ -37,13 +33,14 @@ "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", "label_active": "ACTIVE", "label_standby": "STANDBY", "label_ip": "IP", + "label_ip_public": "Public IP", "label_ipv6": "IPv6", "label_dns": "DNS", "label_isp": "ISP", @@ -89,5 +86,22 @@ "pill_ok": "OK", "pill_fail": "FAIL", "pill_online": "Online", - "pill_offline": "Offline" + "pill_offline": "Offline", + "ports_empty": "No ports defined.", + "port_add": "Add port", + "port_remove": "Remove", + "port_dialog_title": "Add WAN port", + "port_ifname": "Interface", + "port_alias": "Alias", + "port_confirm": "Add", + "port_cancel": "Cancel", + "port_invalid": "Enter a valid interface name, e.g. eth7.", + "port_duplicate": "This interface is already in the list.", + "port_max": "At most 8 ports.", + "primary_wan": "Cable connection (relevant for DOCSight)", + "primary_wan_hint": "The WAN that carries your cable/DOCSIS connection. It is highlighted in the dashboard, and outages on the other WAN are only reported as warnings instead of critical.", + "primary_wan_none": "Not set", + "primary_wan_wan1": "WAN 1", + "primary_wan_wan2": "WAN 2", + "badge_primary": "DOCSight" } diff --git a/udm-wan-monitor/i18n/es.json b/udm-wan-monitor/i18n/es.json index b1c8e65..aa6b944 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)", @@ -18,12 +18,8 @@ "verify_ssl": "Verificar certificado SSL", "verify_ssl_hint": "Desactivar para certificados autofirmados del UDM (recomendado).", "test_connection": "Probar conexión", - "extra_ports_title": "Puertos adicionales opcionales", - "extra_ports_hint": "Monitorizar hasta dos puertos adicionales (ej. un puerto SFP+ como tercer enlace WAN). Ingresa el nombre de interfaz como \"eth7\" y un alias libre. Dejar vacío para desactivar.", - "extra_port1_ifname": "Puerto adicional 1 — Interfaz", - "extra_port1_alias": "Puerto adicional 1 — Alias", - "extra_port2_ifname": "Puerto adicional 2 — Interfaz", - "extra_port2_alias": "Puerto adicional 2 — Alias", + "extra_ports_title": "Puertos WAN (manual)", + "extra_ports_hint": "WAN 1 y WAN 2 se detectan automáticamente. Si tu gateway no informa de ningún WAN, o quieres supervisar otro puerto (p. ej. un enlace SFP+), añádelo aquí por su nombre de interfaz (p. ej. \"eth7\").", "extra_port_ifname_hint": "Nombre de interfaz, ej. eth7", "extra_port_alias_hint": "Nombre a mostrar en el panel", "event_down": "{iface} ({ip}): caído — alive=false, offline", @@ -37,13 +33,14 @@ "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", "label_active": "ACTIVO", "label_standby": "ESPERA", "label_ip": "IP", + "label_ip_public": "IP pública", "label_ipv6": "IPv6", "label_dns": "DNS", "label_isp": "ISP", @@ -89,5 +86,22 @@ "pill_ok": "OK", "pill_fail": "FAIL", "pill_online": "En línea", - "pill_offline": "Sin conexión" + "pill_offline": "Sin conexión", + "ports_empty": "No hay puertos definidos.", + "port_add": "Añadir puerto", + "port_remove": "Quitar", + "port_dialog_title": "Añadir puerto WAN", + "port_ifname": "Interfaz", + "port_alias": "Alias", + "port_confirm": "Añadir", + "port_cancel": "Cancelar", + "port_invalid": "Introduce un nombre de interfaz válido, p. ej. eth7.", + "port_duplicate": "Esta interfaz ya está en la lista.", + "port_max": "Máximo 8 puertos.", + "primary_wan": "Conexión por cable (relevante para DOCSight)", + "primary_wan_hint": "El WAN que transporta tu conexión de cable/DOCSIS. Se resalta en el panel y las caídas del otro WAN solo se notifican como avisos en lugar de críticas.", + "primary_wan_none": "Sin definir", + "primary_wan_wan1": "WAN 1", + "primary_wan_wan2": "WAN 2", + "badge_primary": "DOCSight" } diff --git a/udm-wan-monitor/i18n/fr.json b/udm-wan-monitor/i18n/fr.json index d072006..8772cd8 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)", @@ -18,12 +18,8 @@ "verify_ssl": "Vérifier le certificat SSL", "verify_ssl_hint": "Désactiver pour les certificats auto-signés de l'UDM (recommandé).", "test_connection": "Tester la connexion", - "extra_ports_title": "Ports supplémentaires optionnels", - "extra_ports_hint": "Surveiller jusqu'à deux ports supplémentaires (ex. un port SFP+ comme troisième uplink WAN). Entrez le nom d'interface comme \"eth7\" et un alias libre. Laisser vide pour désactiver.", - "extra_port1_ifname": "Port supplémentaire 1 — Interface", - "extra_port1_alias": "Port supplémentaire 1 — Alias", - "extra_port2_ifname": "Port supplémentaire 2 — Interface", - "extra_port2_alias": "Port supplémentaire 2 — Alias", + "extra_ports_title": "Ports WAN (manuel)", + "extra_ports_hint": "WAN 1 et WAN 2 sont détectés automatiquement. Si votre passerelle ne signale aucun WAN, ou pour surveiller un autre port (p. ex. une liaison SFP+), ajoutez-le ici par son nom d'interface (p. ex. \"eth7\").", "extra_port_ifname_hint": "Nom d'interface, ex. eth7", "extra_port_alias_hint": "Nom affiché dans le tableau de bord", "event_down": "{iface} ({ip}) : hors ligne — alive=false, offline", @@ -37,13 +33,14 @@ "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", "label_active": "ACTIF", "label_standby": "VEILLE", "label_ip": "IP", + "label_ip_public": "IP publique", "label_ipv6": "IPv6", "label_dns": "DNS", "label_isp": "FAI", @@ -89,5 +86,22 @@ "pill_ok": "OK", "pill_fail": "FAIL", "pill_online": "En ligne", - "pill_offline": "Hors ligne" + "pill_offline": "Hors ligne", + "ports_empty": "Aucun port défini.", + "port_add": "Ajouter un port", + "port_remove": "Supprimer", + "port_dialog_title": "Ajouter un port WAN", + "port_ifname": "Interface", + "port_alias": "Alias", + "port_confirm": "Ajouter", + "port_cancel": "Annuler", + "port_invalid": "Saisissez un nom d'interface valide, p. ex. eth7.", + "port_duplicate": "Cette interface figure déjà dans la liste.", + "port_max": "8 ports au maximum.", + "primary_wan": "Connexion câble (pertinente pour DOCSight)", + "primary_wan_hint": "Le WAN qui porte votre connexion câble/DOCSIS. Il est mis en évidence dans le tableau de bord, et les pannes de l'autre WAN ne sont signalées que comme avertissements au lieu de critiques.", + "primary_wan_none": "Non défini", + "primary_wan_wan1": "WAN 1", + "primary_wan_wan2": "WAN 2", + "badge_primary": "DOCSight" } diff --git a/udm-wan-monitor/i18n/template.json b/udm-wan-monitor/i18n/template.json index 9cfadd8..b032632 100644 --- a/udm-wan-monitor/i18n/template.json +++ b/udm-wan-monitor/i18n/template.json @@ -20,10 +20,6 @@ "test_connection": "", "extra_ports_title": "", "extra_ports_hint": "", - "extra_port1_ifname": "", - "extra_port1_alias": "", - "extra_port2_ifname": "", - "extra_port2_alias": "", "extra_port_ifname_hint": "", "extra_port_alias_hint": "", "event_down": "", @@ -44,6 +40,7 @@ "label_active": "", "label_standby": "", "label_ip": "", + "label_ip_public": "", "label_ipv6": "", "label_dns": "", "label_isp": "", @@ -89,5 +86,22 @@ "pill_ok": "", "pill_fail": "", "pill_online": "", - "pill_offline": "" + "pill_offline": "", + "ports_empty": "", + "port_add": "", + "port_remove": "", + "port_dialog_title": "", + "port_ifname": "", + "port_alias": "", + "port_confirm": "", + "port_cancel": "", + "port_invalid": "", + "port_duplicate": "", + "port_max": "", + "primary_wan": "", + "primary_wan_hint": "", + "primary_wan_none": "", + "primary_wan_wan1": "", + "primary_wan_wan2": "", + "badge_primary": "" } diff --git a/udm-wan-monitor/manifest.json b/udm-wan-monitor/manifest.json index 7f404da..e927d83 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.3.0", "author": "Oggy512", "minAppVersion": "2026.2", "type": "integration", @@ -28,6 +28,8 @@ "udm_wan_site": "default", "udm_wan_verify_ssl": false, "udm_wan_interval": 60, + "udm_wan_primary": "", + "udm_wan_extra_ports": "", "udm_wan_extra_port1_ifname": "", "udm_wan_extra_port1_alias": "", "udm_wan_extra_port2_ifname": "", diff --git a/udm-wan-monitor/routes.py b/udm-wan-monitor/routes.py index de7543a..c568ef5 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.3.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 ────────────────────────────────────────────────────────────────────── @@ -125,54 +131,28 @@ def api_detail(): _collector = _collector_mod() _fetch_udm_device = _collector._fetch_udm_device parse_device = _collector.parse_device - device = _fetch_udm_device(session, cfg) + device = _fetch_udm_device( + session, cfg, allow_no_wan=bool(_collector.parse_extra_ports(_cfg())) + ) parsed = parse_device(device) except req.exceptions.ConnectionError: 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 - # ── Optional extra ports from config ──────────────────────────────────────── - c = _cfg() - extra_ports_cfg = [] - for i in (1, 2): - ifname = (c.get(f"udm_wan_extra_port{i}_ifname") or "").strip() - alias = (c.get(f"udm_wan_extra_port{i}_alias") or "").strip() - 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)) + extra_ports_cfg = _collector.parse_extra_ports(_cfg()) + + wan_ports = _collector.build_wan_ports(device, parsed, extra_ports_cfg) parsed["wan_ports"] = wan_ports + parsed["primary_wan"] = _collector.primary_wan(_cfg()) return jsonify({ "ok": True, @@ -189,11 +169,13 @@ 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) + device = _fetch_udm_device( + session, cfg, allow_no_wan=bool(_collector.parse_extra_ports(_cfg())) + ) parsed = parse_device(device) return jsonify({ "ok": True, @@ -204,6 +186,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..fe98b12 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 @@ #}
-
+
{# ── Card header ───────────────────────────────────────────────── #}
@@ -17,10 +17,10 @@
- {{ t.get('community.udm_wan_monitor.settings_title', 'Ubiquiti UDM WAN-Monitor') }} + {{ t.get('community.udm_wan_monitor.settings_title', 'UniFi WAN-Monitor') }}
- {{ t.get('community.udm_wan_monitor.settings_subtitle', 'WAN1 & WAN2 überwachen · Events im globalen Log · UDM Pro / UDM SE') }} + {{ t.get('community.udm_wan_monitor.settings_subtitle', 'WAN1 & WAN2 überwachen · Events im globalen Log · UDM / UCG') }}
@@ -34,7 +34,7 @@
- {{ t.get('community.udm_wan_monitor.enabled', 'UDM WAN-Monitor aktivieren') }} + {{ t.get('community.udm_wan_monitor.enabled', 'UniFi WAN-Monitor aktivieren') }} {{ t.get('community.udm_wan_monitor.enabled_hint', 'Startet die Überwachung und schreibt WAN-Events in das globale DOCSight-Ereignislog.') }} @@ -59,7 +59,7 @@ value="{{ config.udm_wan_host }}" placeholder="192.168.1.1" autocomplete="off"> - {{ t.get('community.udm_wan_monitor.host_hint', 'IP-Adresse oder Hostname der UDM Pro (HTTPS, Standard-Port 443)') }} + {{ t.get('community.udm_wan_monitor.host_hint', 'IP-Adresse oder Hostname des Gateways (UDM/UCG) (HTTPS, Standard-Port 443)') }}
@@ -115,6 +115,20 @@
+
+ + + + {{ t.get('community.udm_wan_monitor.primary_wan_hint', 'The WAN that carries your cable/DOCSIS connection. It is highlighted in the dashboard, and outages on the other WAN are only reported as warnings instead of critical.') }} + +
+
{# ── SSL toggle ────────────────────────────────────────────────── #} @@ -135,58 +149,58 @@
- {# ── Optional extra ports ──────────────────────────────────────── #} -
+ {% set legacy_ports = [] %} + {% if config.udm_wan_extra_port1_ifname %}{% set _ = legacy_ports.append({'ifname': config.udm_wan_extra_port1_ifname, 'alias': config.udm_wan_extra_port1_alias or ''}) %}{% endif %} + {% if config.udm_wan_extra_port2_ifname %}{% set _ = legacy_ports.append({'ifname': config.udm_wan_extra_port2_ifname, 'alias': config.udm_wan_extra_port2_alias or ''}) %}{% endif %} +

- {{ 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").') }}

-
-
- - - {{ t.get('community.udm_wan_monitor.extra_port_ifname_hint', 'Interface-Name, z.B. eth7') }} -
-
- - - {{ t.get('community.udm_wan_monitor.extra_port_alias_hint', 'Anzeigename im Dashboard') }} -
+ +
+ +
-
- - + + + {# ── Test connection ────────────────────────────────────────────── #}
@@ -250,3 +264,101 @@ window.udmWanTest = udmWanTest; })(); + + diff --git a/udm-wan-monitor/templates/udm_wan_standalone.html b/udm-wan-monitor/templates/udm_wan_standalone.html index e7f1972..41776d8 100644 --- a/udm-wan-monitor/templates/udm_wan_standalone.html +++ b/udm-wan-monitor/templates/udm_wan_standalone.html @@ -3,7 +3,7 @@ - UDM WAN-Monitor — DOCSight + UniFi WAN-Monitor — DOCSight diff --git a/udm-wan-monitor/templates/udm_wan_tab.html b/udm-wan-monitor/templates/udm_wan_tab.html index 69de79b..81317f1 100644 --- a/udm-wan-monitor/templates/udm_wan_tab.html +++ b/udm-wan-monitor/templates/udm_wan_tab.html @@ -1,5 +1,5 @@ {# - UDM WAN Monitor — Tab Template v3.1.1 + UniFi WAN Monitor — Tab Template v3.1.1 Included via {% include %} into DOCSight's index.html. All user-visible strings use {{ t['community.udm_wan_monitor.key'] or 'English fallback' }} so they are translated via i18n/de.json, fr.json, es.json automatically. @@ -52,6 +52,7 @@ #udm-wan-view .s-unk { background:rgba(120,130,180,.1);color:var(--text-muted); } #udm-wan-view .s-active { background:rgba(16,185,129,.15);color:var(--good,#10b981); font-weight:800; } +#udm-wan-view .s-primary { background:rgba(120,130,180,.15);color:var(--text);font-weight:700; } #udm-wan-view .s-standby { background:rgba(59,130,246,.12);color:var(--sapphire,#3b82f6); } #udm-wan-view .s-ok .sdot, #udm-wan-view .s-active .sdot { animation:udmpulse 2s infinite; } @@ -120,9 +121,9 @@ {# ── Header ────────────────────────────────────────────────────────────────── #}
-
UDM WAN-Monitor
+
UniFi WAN-Monitor
v3.1.1
-
{{ T('device_subtitle', 'Ubiquiti UDM Pro') }}
+
{{ T('device_subtitle', 'Ubiquiti Gateway') }}
@@ -144,14 +145,14 @@ {# ── WAN 1 + WAN 2 ─────────────────────────────────────────────────────────── #}
-
+
WAN 1
-
eth9
+
—
@@ -167,6 +168,7 @@ {{ T('label_wan', 'WAN') }} … + @@ -175,6 +177,7 @@
{{ T('label_ip', 'IP') }} —
+
{{ T('label_ipv6', 'IPv6') }} —
{{ T('label_dns', 'DNS') }} —
{{ T('label_isp', 'ISP') }} —
@@ -190,14 +193,14 @@
-
+
WAN 2
-
eth8
+
—
@@ -213,6 +216,7 @@ {{ T('label_wan', 'WAN') }} … + @@ -221,6 +225,7 @@
{{ T('label_ip', 'IP') }} —
+
{{ T('label_ipv6', 'IPv6') }} —
{{ T('label_dns', 'DNS') }} —
{{ T('label_isp', 'ISP') }} —
@@ -407,12 +412,20 @@ } function fillWan(px, w) { + if(w && !w.ifname) w=null; pill(px+'-link', px+'-link-txt', w&&w.up, L.up, L.down); pill(px+'-alive', px+'-alive-txt', w&&w.alive, L.ok, L.fail); pill(px+'-online', px+'-online-txt', w&&w.online, L.online, L.offline); setRole(px, w); if(!w) return; + txt(px+'-ifname', w.ifname); txt(px+'-ip', w.ip); + const pubRow=el(px+'-ippub-row'); + if(pubRow){ + const showPub=!!w.ip_public && w.ip_public!==w.ip; + pubRow.style.display=showPub?'':'none'; + if(showPub) txt(px+'-ippub', w.ip_public); + } txt(px+'-ip6', w.ipv6); txt(px+'-dns', w.dns); txt(px+'-isp', w.isp && w.city ? w.isp+' ('+w.city+')' : (w.isp||w.city||null)); @@ -427,6 +440,15 @@ txt(px+'-spd', fSpd(w.speed)); } +function markPrimary(primary) { + ['w1','w2'].forEach(function(px){ + const isPrimary = (px==='w1'&&primary==='wan1') || (px==='w2'&&primary==='wan2'); + const badge=el(px+'-primary'), card=el(px+'-card'); + if(badge) badge.style.display=isPrimary?'inline-flex':'none'; + if(card) card.style.order=isPrimary?'-1':'0'; + }); +} + function fillDevice(dev) { if(!dev) return; txt('dev-model', dev.model); @@ -502,6 +524,7 @@ if(errEl) errEl.style.display='none'; fillWan('w1', d.wan1||null); fillWan('w2', d.wan2||null); + markPrimary(d.primary_wan||null); fillDevice(d.device||{}); fillPorts(d.wan_ports||[]); const ts=el('udm-last-ts');