diff --git a/conf.py b/conf.py index 4e72fddb..7d8cd85b 100644 --- a/conf.py +++ b/conf.py @@ -38,7 +38,7 @@ def ultimateReplace(app, docname, source): source[0] = result ultimate_replacements = { - "{deb_url_v1_5}" : "libreqos_2.0.202603200330-1_amd64.deb" + "{deb_url_v1_5}" : "libreqos_2.1.202604011348-1_amd64.deb" } def setup(app): diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index 5e3dc84f..f2d8732c 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -81,6 +81,20 @@ If integration mode is enabled, integration refresh cycles typically own `Shaped - Use WebUI/manual edits for short operational adjustments only. - Put permanent changes in your integration system, integration overrides, or declared external source of truth workflow. +#### IP range allow/ignore behavior for integrations + +The `[ip_ranges]` section is also used when integrations generate subscriber/device shaping data. + +- `allow_subnets` defines the address space LibreQoS should consider shapeable. +- `ignore_subnets` removes matching addresses from generated subscriber devices even if they are otherwise present in the source CRM/NMS. +- Current shared integration-output pruning uses `ignore_subnets` to exclude generated subscriber/device rows, but does not newly require all imported integration IPs to be inside `allow_subnets` just to survive generation. +- If an imported device is left with no remaining non-ignored IPs after `ignore_subnets` is applied, that device is omitted from generated `ShapedDevices.csv`. +- If an imported circuit is left with no remaining shaped devices, that circuit is omitted from generated `ShapedDevices.csv`. + +This can be used to exclude entire subscriber populations from LibreQoS shaping and Insight-reported shaped-device inventory. For example, some operators shape wireless subscribers in LibreQoS but exclude FTTH subscribers whose ONTs already enforce service rates. + +Use this carefully: `ignore_subnets` is broader than a billing-only toggle. The same setting also affects other LibreQoS/Insight IP-policy handling. + #### CRM/NMS Integrations Learn more about [configuring integrations here](integrations.md). diff --git a/docs/v2.0/integrations-sonar.md b/docs/v2.0/integrations-sonar.md index 1bf10440..d5c93ebb 100644 --- a/docs/v2.0/integrations-sonar.md +++ b/docs/v2.0/integrations-sonar.md @@ -5,7 +5,11 @@ First, set the relevant parameters for Sonar (sonar_api_key, sonar_api_url, etc. Current behavior notes: - `sonar_api_url` may be either the Sonar base URL or the full GraphQL endpoint. LibreQoS normalizes it to `/api/graphql` automatically. - Current builds page through Sonar GraphQL results instead of relying on a small first-page sample. +- Paginated Sonar GraphQL requests now use a split connect/read timeout and retry transient read timeouts before failing the import. - Emitted Sonar identities are namespaced (for example `sonar:account:` and `sonar:device:`) so they remain stable across overrides and downstream tooling. +- Account device discovery now preserves inventory-item IP handling and also imports Radius account IP assignments when they exist. Inventory-backed MACs are still used for AP mapping; Radius-only IPs are added as supplemental shaping devices and overlapping subnets are de-duplicated. +- Sonar `child_accounts` are also imported when they expose their own service and usable IP data. If a child account lacks its own address, LibreQoS falls back to the parent account address so the child can still be emitted as its own circuit. +- Sonar settings now support ISP-specific recurring-service fallback rates plus a recurring-service exclusion list. LibreQoS still prefers active `DATA` services first; recurring mappings are only used when an account has no usable `DATA` service. - If Sonar returns non-JSON content or GraphQL errors, the integration now raises a more specific error message showing the endpoint and a short response preview. To test the Sonar Integration, use diff --git a/src/build_dpkg.sh b/src/build_dpkg.sh index 276e528b..22af7c84 100755 --- a/src/build_dpkg.sh +++ b/src/build_dpkg.sh @@ -134,15 +134,14 @@ fi # Install Python Dependencies pushd /opt/libreqos > /dev/null -# - Setup Python dependencies as a post-install task +# - Setup Python dependencies as a post-install task. Use --ignore-installed so +# pip does not try to uninstall Debian-managed packages that do not ship a +# pip RECORD file (for example blinker on Ubuntu 24.04). if [ -s src/deb-requirements-constraints.txt ]; then -PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install -c src/deb-requirements-constraints.txt -r src/requirements.txt +PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -c src/deb-requirements-constraints.txt -r src/requirements.txt else -PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install -r src/requirements.txt +PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --ignore-installed -r src/requirements.txt fi -# - Setup Python dependencies as a post-install task - handle issue with packages on Ubuntu Server 24.04 -PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip uninstall apscheduler deepdiff --yes -PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install apscheduler deepdiff # Ensure folder permissions are correct post-install sudo chown -R root:root /opt/libreqos diff --git a/src/integrationCommon.py b/src/integrationCommon.py index 9ea9e6d9..5e5f9d4a 100644 --- a/src/integrationCommon.py +++ b/src/integrationCommon.py @@ -41,6 +41,18 @@ def isIpv4Permitted(inputIP): return isInIgnoredSubnets(inputIP) == False and isInAllowedSubnets(inputIP) +def isIpPermitted(inputIP): + # Generic form of subnet permission checking used by integrations. + # The configured allow/ignore ranges may include either IPv4 or IPv6. + return isIpv4Permitted(inputIP) + + +def isIntegrationOutputIpAllowed(inputIP): + # Shared integration-output pruning is intentionally narrower than + # full "permitted" checks: only explicitly ignored subnets are removed. + return not isInIgnoredSubnets(inputIP) + + def fixSubnet(inputIP): # If an IP address has a CIDR other than /32 (e.g. 192.168.1.1/24), # but doesn't appear as a network address (e.g. 192.168.1.0/24) @@ -252,6 +264,7 @@ class NetworkGraph: # O(1) lookup instead of O(n) search parentIdx = self._id_to_index.get(parent, 0) node.parentIndex = parentIdx + node.parentId = parent idx = len(self.nodes) self.nodes.append(node) @@ -269,6 +282,7 @@ class NetworkGraph: root_list_set = set(cached_root_list) for child in self.nodes: + child.parentIndex = 0 if child.parentId != "": # O(1) lookup instead of O(n) search parent_idx = self._id_to_index.get(child.parentId, -1) @@ -330,26 +344,28 @@ class NetworkGraph: if not self._cache_valid: self._buildChildrenCache() - for i, node in enumerate(self.nodes): - if node.type == NodeType.clientWithChildren: - siteNode = NetworkNode( - id=str(node.id) + "_gen", - displayName="(Generated Site) " + node.displayName, - type=NodeType.site, - networkJsonId=syntheticNetworkJsonId("graph", "site", node.displayName), - ) - siteNode.parentIndex = node.parentIndex - node.parentId = siteNode.id - node.type = NodeType.client - - # Store reparenting operations for batch processing - children = self._children_cache.get(i, []) - for child_idx in children: - child = self.nodes[child_idx] - if child.type in (NodeType.client, NodeType.clientWithChildren, NodeType.site): - reparent_map[child_idx] = siteNode.id - - toAdd.append(siteNode) + for i, node in enumerate(self.nodes): + if node.type == NodeType.clientWithChildren: + original_parent_id = node.parentId + siteNode = NetworkNode( + id=str(node.id) + "_gen", + displayName="(Generated Site) " + node.displayName, + type=NodeType.site, + networkJsonId=syntheticNetworkJsonId("graph", "site", node.displayName), + ) + siteNode.parentIndex = node.parentIndex + siteNode.parentId = original_parent_id + node.parentId = siteNode.id + node.type = NodeType.client + + # Store reparenting operations for batch processing + children = self._children_cache.get(i, []) + for child_idx in children: + child = self.nodes[child_idx] + if child.type in (NodeType.client, NodeType.clientWithChildren, NodeType.site): + reparent_map[child_idx] = siteNode.id + + toAdd.append(siteNode) # Batch add new nodes for n in toAdd: @@ -362,12 +378,12 @@ class NetworkGraph: self.__reparentById() def __findUnconnectedNodes(self) -> List: - # OPTIMIZED: Uses set for O(1) membership testing + # Rebuild the cache each time because callers may have adjusted + # parentIndex directly as part of repair/testing flows. visited = set() next = [0] - if not self._cache_valid: - self._buildChildrenCache() + self._buildChildrenCache() while next: current = next.pop() @@ -422,6 +438,8 @@ class NetworkGraph: self.__clientsWithChildrenToSites() print("PrepareTree: Reconnecting unconnected nodes...") self.__reconnectUnconnected() + print("PrepareTree: Pruning ignored-only devices and empty circuits...") + self.__pruneIgnoredCircuits() print("PrepareTree: Complete") def doesNetworkJsonExist(self): @@ -502,6 +520,55 @@ class NetworkGraph: if ip in self.ipv4ToIPv6.keys(): ipv6.append(self.ipv4ToIPv6[ip]) + def __rebuildIndexes(self) -> None: + self._id_to_index = {} + self._name_to_index = {} + for idx, node in enumerate(self.nodes): + self._id_to_index[node.id] = idx + self._name_to_index[node.displayName] = idx + self._cache_valid = False + + def __filterPermittedIps(self, ip_list: List[str]) -> List[str]: + return [ip for ip in ip_list if isIntegrationOutputIpAllowed(ip)] + + def __pruneIgnoredCircuits(self) -> None: + for node in self.nodes: + if node.type == NodeType.device: + node.ipv4 = self.__filterPermittedIps(node.ipv4) + node.ipv6 = self.__filterPermittedIps(node.ipv6) + + if not self._cache_valid: + self._buildChildrenCache() + + removable = set() + for idx, node in enumerate(self.nodes): + if node.type == NodeType.device and (len(node.ipv4) + len(node.ipv6) == 0): + removable.add(idx) + + for idx, node in enumerate(self.nodes): + if node.type != NodeType.client: + continue + has_shapable_device = False + for child_idx in self._children_cache.get(idx, []): + if child_idx in removable: + continue + child = self.nodes[child_idx] + if child.type == NodeType.device and (len(child.ipv4) + len(child.ipv6) > 0): + has_shapable_device = True + break + if not has_shapable_device: + removable.add(idx) + + if not removable: + return + + self.nodes = [ + node for idx, node in enumerate(self.nodes) + if idx not in removable + ] + self.__rebuildIndexes() + self.__reparentById() + def createShapedDevices(self): import csv # Builds ShapedDevices.csv from the network tree. diff --git a/src/integrationNetzur.py b/src/integrationNetzur.py index d62156f9..db09e308 100644 --- a/src/integrationNetzur.py +++ b/src/integrationNetzur.py @@ -62,6 +62,28 @@ def fetch_netzur_data() -> Tuple[List[dict], List[dict]]: def _build_exclusion_set() -> set: return {entry.lower().strip() for entry in exclude_sites() if entry} + +def _split_ip_field(raw_value) -> List[str]: + values: List[str] = [] + if raw_value in (None, ""): + return values + + if isinstance(raw_value, (list, tuple, set)): + parts = raw_value + else: + parts = str(raw_value).split(",") + + seen = set() + for part in parts: + entry = str(part).strip() + if not entry or entry in seen: + continue + seen.add(entry) + values.append(entry) + + return values + + def createShaper() -> NetworkGraph: LOG.info("[Netzur] Starting sync") if integration_common_use_mikrotik_ipv6(): @@ -122,8 +144,8 @@ def createShaper() -> NetworkGraph: ) ) - ipv4 = [subscriber["ip"]] if subscriber.get("ip") else [] - ipv6 = [subscriber["ipv6"]] if subscriber.get("ipv6") else [] + ipv4 = _split_ip_field(subscriber.get("ip")) + ipv6 = _split_ip_field(subscriber.get("ipv6")) mac = str(subscriber.get("mac") or "").strip() if ipv4 or ipv6 or mac: net.addRawNode( diff --git a/src/integrationSonar.py b/src/integrationSonar.py index bb8919e1..581e45b6 100644 --- a/src/integrationSonar.py +++ b/src/integrationSonar.py @@ -2,9 +2,11 @@ from pythonCheck import checkPythonVersion checkPythonVersion() import requests import subprocess +import time from urllib.parse import urlsplit, urlunsplit from liblqos_python import sonar_api_key, sonar_api_url, snmp_community, sonar_airmax_ap_model_ids, \ - sonar_ltu_ap_model_ids, sonar_active_status_ids + sonar_ltu_ap_model_ids, sonar_active_status_ids, sonar_recurring_service_rates, \ + sonar_recurring_excluded_service_names all_models = sonar_airmax_ap_model_ids() + sonar_ltu_ap_model_ids() from integrationCommon import NetworkGraph, NetworkNode, NodeType, apply_client_bandwidth_multiplier from multiprocessing.pool import ThreadPool @@ -25,6 +27,13 @@ from multiprocessing.pool import ThreadPool # There should probably be a log file somewhere and output status to the Web UI. # If snmp fails to get the name of the AP then it's just called "Not found via snmp" We won't see that happen unless it is able to get the connected cpe and not the name. +_SONAR_SESSION = None +SONAR_CONNECT_TIMEOUT_SECONDS = 10 +SONAR_READ_TIMEOUT_SECONDS = 60 +SONAR_REQUEST_TIMEOUT = (SONAR_CONNECT_TIMEOUT_SECONDS, SONAR_READ_TIMEOUT_SECONDS) +SONAR_PAGINATED_MAX_ATTEMPTS = 3 +SONAR_PAGINATED_RETRY_BACKOFF_SECONDS = (2, 5) + def sonarGraphqlUrl(): raw_url = sonar_api_url().strip() @@ -52,10 +61,58 @@ def sonarDeviceNodeId(device_id): return f"sonar:device:{device_id}" -def sonarRequest(query,variables={}): +def sonarRadiusAccountDeviceNodeId(radius_account_id): + return f"sonar:radius-account:{radius_account_id}" + + +def sonarSession(): + global _SONAR_SESSION + if _SONAR_SESSION is None: + _SONAR_SESSION = requests.Session() + _SONAR_SESSION.headers.update({ + 'Authorization': 'Bearer ' + sonar_api_key() + }) + return _SONAR_SESSION + + +def sonarPostWithRetry(session, graphql_url, payload, timeout, request_label, max_attempts=1, retry_backoffs=()): + last_error = None + for attempt in range(1, max_attempts + 1): + try: + return session.post( + graphql_url, + json=payload, + timeout=timeout + ) + except requests.exceptions.ReadTimeout as error: + last_error = error + if attempt >= max_attempts: + break + backoff_seconds = 0 + if retry_backoffs: + backoff_index = min(attempt - 1, len(retry_backoffs) - 1) + backoff_seconds = retry_backoffs[backoff_index] + if backoff_seconds > 0: + time.sleep(backoff_seconds) + + raise RuntimeError( + f"Sonar API at {graphql_url} timed out while reading {request_label} " + f"after {max_attempts} attempts" + ) from last_error + + +def sonarRequest(query, variables=None): + if variables is None: + variables = {} graphql_url = sonarGraphqlUrl() - r = requests.post(graphql_url, json={'query': query, 'variables': variables}, headers={'Authorization': 'Bearer ' + sonar_api_key()}, timeout=10) + r = sonarPostWithRetry( + sonarSession(), + graphql_url, + {'query': query, 'variables': variables}, + SONAR_REQUEST_TIMEOUT, + "Sonar request" + ) try: r_json = r.json() except requests.exceptions.JSONDecodeError as e: @@ -80,6 +137,8 @@ def sonarPaginatedRequest(query, variables=None, paginator_key='pages', records_ if variables is None: variables = {} + session = sonarSession() + graphql_url = sonarGraphqlUrl() page = 1 entities = [] @@ -89,12 +148,14 @@ def sonarPaginatedRequest(query, variables=None, paginator_key='pages', records_ 'records_per_page': records_per_page, 'page': page } - graphql_url = sonarGraphqlUrl() - r = requests.post( + r = sonarPostWithRetry( + session, graphql_url, - json={'query': query, 'variables': request_variables}, - headers={'Authorization': 'Bearer ' + sonar_api_key()}, - timeout=20 + {'query': query, 'variables': request_variables}, + SONAR_REQUEST_TIMEOUT, + f"Sonar page {page}", + max_attempts=SONAR_PAGINATED_MAX_ATTEMPTS, + retry_backoffs=SONAR_PAGINATED_RETRY_BACKOFF_SECONDS, ) try: r_json = r.json() @@ -151,6 +212,81 @@ def findIPs(inventory_item): return ips +def dedupeSubnets(subnets): + deduped = [] + seen = set() + for subnet in subnets: + if not subnet or subnet in seen: + continue + seen.add(subnet) + deduped.append(subnet) + return deduped + + +def normalizeServiceName(name): + return (name or "").strip().casefold() + + +def findRadiusAccountIPs(radius_account): + ips = [] + for ip in radius_account.get('ip_assignments', {}).get('entities', []): + subnet = ip.get('subnet') + if subnet: + ips.append(subnet) + return dedupeSubnets(ips) + + +def recurringServiceRateRules(): + rules = {} + for enabled, service_name, download_mbps, upload_mbps in sonar_recurring_service_rates(): + normalized_name = normalizeServiceName(service_name) + if not enabled or not normalized_name: + continue + rules[normalized_name] = ( + float(download_mbps), + float(upload_mbps), + ) + return rules + + +def recurringServiceExclusions(): + return { + normalizeServiceName(name) + for name in sonar_recurring_excluded_service_names() + if normalizeServiceName(name) + } + + +def resolveAccountBandwidth(account, recurring_rules=None, excluded_names=None): + account_services = account.get('account_services', {}).get('entities', []) + if account_services: + service = account_services[0].get('service') or {} + service_detail = service.get('data_service_detail') + if service_detail: + return ( + float(service_detail['download_speed_kilobits_per_second']) / 1000, + float(service_detail['upload_speed_kilobits_per_second']) / 1000, + ) + + if recurring_rules is None: + recurring_rules = recurringServiceRateRules() + if not recurring_rules: + return None + if excluded_names is None: + excluded_names = recurringServiceExclusions() + all_services = account.get('all_account_services', {}).get('entities', []) + for row in all_services: + service = row.get('service') or {} + if service.get('type') != 'RECURRING': + continue + normalized_name = normalizeServiceName(service.get('name')) + if not normalized_name or normalized_name in excluded_names: + continue + if normalized_name in recurring_rules: + return recurring_rules[normalized_name] + return None + + def findPrimaryMac(inventory_item): field_data_entities = inventory_item.get('inventory_model_field_data', {}).get('entities', []) @@ -177,6 +313,21 @@ def findPrimaryMac(inventory_item): return value return "" + +def formatAccountAddress(account): + addresses = account.get('addresses', {}).get('entities', []) + if not addresses: + return "" + primary_address = addresses[0] + line1 = primary_address.get('line1') or "" + line2 = primary_address.get('line2') or "" + city = primary_address.get('city') or "" + subdivision = primary_address.get('subdivision') or "" + state = subdivision[-2:] if subdivision else "" + + address = f"{line1},{f' {line2},' if line2 else ''} {city}, {state}".strip() + return address.strip(', ') + def getSitesAndAps(): query = """query getSitesAndAps($pages: Paginator, $rr_ap_models: ReverseRelationFilter, $ap_models: Search){ network_sites (paginator: $pages,reverse_relation_filters: [$rr_ap_models]) { @@ -248,6 +399,114 @@ def getSitesAndAps(): sites.append({'id': f"site_{site['id']}", 'raw_id': site['id'], 'name': site['name']}) return sites, aps + +def buildAccountDevices(account): + devices = [] + known_ip_subnets = set() + + for address in account.get('addresses', {}).get('entities', []): + for item in address.get('inventory_items', {}).get('entities', []): + ips = dedupeSubnets(findIPs(item)) + known_ip_subnets.update(ips) + devices.append({ + 'id': sonarDeviceNodeId(item['id']), + 'raw_id': item['id'], + 'name': item['inventory_model']['name'], + 'ips': ips, + 'mac': findPrimaryMac(item) + }) + + for radius_account in account.get('radius_accounts', {}).get('entities', []): + radius_ips = [ + subnet for subnet in findRadiusAccountIPs(radius_account) + if subnet not in known_ip_subnets + ] + if not radius_ips: + continue + known_ip_subnets.update(radius_ips) + devices.append({ + 'id': sonarRadiusAccountDeviceNodeId(radius_account['id']), + 'raw_id': radius_account['id'], + 'name': f"Radius Account {radius_account['id']}", + 'ips': radius_ips, + 'mac': '' + }) + + return devices + + +def buildAccountRecord(account, fallback_address="", recurring_rules=None, excluded_names=None): + address = formatAccountAddress(account) or fallback_address + if not address: + return None + + devices = buildAccountDevices(account) + if not devices: + return None + + bandwidth = resolveAccountBandwidth( + account, + recurring_rules=recurring_rules, + excluded_names=excluded_names, + ) + if bandwidth is None: + return None + + download_raw, upload_raw = bandwidth + download = apply_client_bandwidth_multiplier(download_raw) + upload = apply_client_bandwidth_multiplier(upload_raw) + if download < 2: + download = 2 + if upload < 2: + upload = 2 + + return { + 'id': sonarAccountNodeId(account['id']), + 'raw_id': account['id'], + 'name': account['name'], + 'address': address, + 'download': download, + 'upload': upload, + 'devices': devices + } + + +def buildAccountsFromSonarEntities(accounts_from_sonar, recurring_rules=None, excluded_names=None): + accounts = [] + seen_account_ids = set() + child_candidates = [] + + for account in accounts_from_sonar: + account_record = buildAccountRecord( + account, + recurring_rules=recurring_rules, + excluded_names=excluded_names, + ) + if account_record: + accounts.append(account_record) + seen_account_ids.add(account_record['raw_id']) + + parent_address = formatAccountAddress(account) + for child_account in account.get('child_accounts', {}).get('entities', []): + child_candidates.append((child_account, parent_address)) + + for child_account, parent_address in child_candidates: + child_id = child_account['id'] + if child_id in seen_account_ids: + continue + child_record = buildAccountRecord( + child_account, + fallback_address=parent_address, + recurring_rules=recurring_rules, + excluded_names=excluded_names, + ) + if child_record: + accounts.append(child_record) + seen_account_ids.add(child_record['raw_id']) + + return accounts + + def getAccounts(sonar_active_status_ids): query = """query getAccounts ($pages: Paginator, $account_search: Search, $data: ReverseRelationFilter) { accounts (paginator: $pages,search: [$account_search]) { @@ -307,6 +566,106 @@ def getAccounts(sonar_active_status_ids): } } } + radius_accounts { + entities { + id + ip_assignments { + entities { + subnet + } + } + } + } + child_accounts { + entities { + id + name + account_services (reverse_relation_filters: [$data]) { + entities { + service { + data_service_detail { + download_speed_kilobits_per_second + upload_speed_kilobits_per_second + } + } + } + } + addresses { + entities { + line1 + line2 + city + subdivision + inventory_items { + entities { + id + inventory_model { + name + } + inventory_model_field_data { + entities { + value + ip_assignments { + entities { + subnet + } + } + inventory_model_field { + id + name + primary + } + } + } + ip_assignments { + entities { + subnet + } + } + } + } + } + } + radius_accounts { + entities { + id + ip_assignments { + entities { + subnet + } + } + } + } + all_account_services: account_services { + entities { + service(enabled: true) { + id + name + type + enabled + data_service_detail { + download_speed_kilobits_per_second + upload_speed_kilobits_per_second + } + } + } + } + } + } + all_account_services: account_services { + entities { + service(enabled: true) { + id + name + type + enabled + data_service_detail { + download_speed_kilobits_per_second + upload_speed_kilobits_per_second + } + } + } + } } } }""" @@ -335,41 +694,13 @@ def getAccounts(sonar_active_status_ids): } accounts_from_sonar = sonarPaginatedRequest(query,variables) - accounts = [] - for account in accounts_from_sonar: - # We need to make sure the account has an address because Sonar assignments go account -> address (only 1 per account) -> equipment -> ip assignments unless the IP is assigned to the account directly. - if account['addresses']['entities']: - line1 = account['addresses']['entities'][0]['line1'] - line2 = account['addresses']['entities'][0]['line2'] - city = account['addresses']['entities'][0]['city'] - state = account['addresses']['entities'][0]['subdivision'][-2:] - address = f"{line1},{f' {line2},' if line2 else ''} {city}, {state}" - devices = [] - for item in account['addresses']['entities'][0]['inventory_items']['entities']: - devices.append({ - 'id': sonarDeviceNodeId(item['id']), - 'raw_id': item['id'], - 'name': item['inventory_model']['name'], - 'ips': findIPs(item), - 'mac': findPrimaryMac(item) - }) - if account['account_services']['entities'] and devices: # Make sure there is a data plan and devices on the account. - download = apply_client_bandwidth_multiplier(float(account['account_services']['entities'][0]['service']['data_service_detail']['download_speed_kilobits_per_second'])/1000) - upload = apply_client_bandwidth_multiplier(float(account['account_services']['entities'][0]['service']['data_service_detail']['upload_speed_kilobits_per_second'])/1000) - if download < 2: - download = 2 - if upload < 2: - upload = 2 - accounts.append({ - 'id': sonarAccountNodeId(account['id']), - 'raw_id': account['id'], - 'name': account['name'], - 'address': address, - 'download': download, - 'upload': upload, - 'devices': devices - }) - return accounts + recurring_rules = recurringServiceRateRules() + excluded_names = recurringServiceExclusions() if recurring_rules else set() + return buildAccountsFromSonarEntities( + accounts_from_sonar, + recurring_rules=recurring_rules, + excluded_names=excluded_names, + ) def mapApCpeMacs(ap): macs = [] @@ -391,12 +722,12 @@ def mapApCpeMacs(ap): return ap -def mapMacAP(mac,aps): +def buildMacToApMap(aps): + mac_to_ap = {} for ap in aps: for cpe_mac in ap['cpe_macs']: - if cpe_mac == mac: - return ap['id'] - return None + mac_to_ap[cpe_mac] = ap['id'] + return mac_to_ap def createShaper(): net = NetworkGraph() @@ -417,10 +748,12 @@ def createShaper(): pool.close() pool.join() + mac_to_ap = buildMacToApMap(aps) + # Update customers with the AP to which they are connected. for account in accounts: for device in account['devices']: - account['parent'] = mapMacAP(device['mac'],aps) + account['parent'] = mac_to_ap.get(device['mac']) if account['parent']: break diff --git a/src/lqos.example b/src/lqos.example index 533e4f69..133bae13 100644 --- a/src/lqos.example +++ b/src/lqos.example @@ -120,6 +120,8 @@ snmp_community = "public" airmax_model_ids = [ "" ] ltu_model_ids = [ "" ] active_status_ids = [ "" ] +recurring_excluded_service_names = [] +recurring_service_rates = [] [influxdb] enable_influxdb = false diff --git a/src/rust/graph.dot b/src/rust/graph.dot new file mode 100644 index 00000000..7c10a0a9 --- /dev/null +++ b/src/rust/graph.dot @@ -0,0 +1,661 @@ +digraph { + 0 [ label = "Site { name: \"Macarena\", id: \"93ac62e3-ea04-456f-bb19-bc1f294b08e3\", latitude: Some(31.91146), longitude: Some(-106.67658) }" ] + 1 [ label = "Root { name: \"WestRedd\", id: \"5581d110-004f-415d-ae34-ab04a59b19cd\", latitude: Some(31.861092), longitude: Some(-106.54948) }" ] + 2 [ label = "Site { name: \"LaDobe\", id: \"8b36fbc8-1768-4b13-bd1c-3212d67a0074\", latitude: Some(31.914206), longitude: Some(-106.680504) }" ] + 3 [ label = "Site { name: \"Enchanted\", id: \"fa6befcf-75a8-4e1c-b24a-cb56c345a6d6\", latitude: Some(31.916632), longitude: Some(-106.57687) }" ] + 4 [ label = "Site { name: \"175 Kittyhawk\", id: \"55251a1b-448d-4d3d-aa35-2bdbf25c698c\", latitude: Some(31.862467), longitude: Some(-106.692795) }" ] + 5 [ label = "Site { name: \"2832 Girlscout Lane PoP\", id: \"cfb79dfe-f9ea-4d30-9ffc-3f37521a42ac\", latitude: Some(31.808187), longitude: Some(-106.57663) }" ] + 6 [ label = "Site { name: \"1308 Desierto Seco PoP\", id: \"c95fab9a-c10f-4953-acee-02db2e460ae7\", latitude: Some(31.867725), longitude: Some(-106.56048) }" ] + 7 [ label = "Site { name: \"Medano Heights Apt 4\", id: \"d7d8451a-dc5b-47ab-a19e-70fee3316a47\", latitude: Some(31.870169), longitude: Some(-106.57575) }" ] + 8 [ label = "Site { name: \"Vinton\", id: \"93fe0eae-8733-4dd4-86c4-d34e2fd51f99\", latitude: Some(31.963459), longitude: Some(-106.62187) }" ] + 9 [ label = "Site { name: \"Tuscany Ridge\", id: \"804a3feb-3808-4d1a-b1b1-db9b265e9ab8\", latitude: Some(31.871101), longitude: Some(-106.54482) }" ] + 10 [ label = "Site { name: \"Medano Heights Apt 7\", id: \"e3f33d28-7813-407c-9127-67a3c450a5a8\", latitude: Some(31.870134), longitude: Some(-106.575) }" ] + 11 [ label = "Site { name: \"1300NVintonUnits3and4\", id: \"a634d84d-ffa6-4642-87b4-b8ccd6558efe\", latitude: Some(31.962906), longitude: Some(-106.62146) }" ] + 12 [ label = "Site { name: \"2801 Girl Scout Lane\", id: \"1532675e-d631-4989-8d6f-c38638d2984f\", latitude: Some(31.806168), longitude: Some(-106.57586) }" ] + 13 [ label = "Site { name: \"190 LaDobe PoP\", id: \"d5f253b6-59c4-4226-b4b1-aeec2acd3a6f\", latitude: Some(31.914616), longitude: Some(-106.68102) }" ] + 14 [ label = "Site { name: \"MDC El Paso\", id: \"1ce2fc56-4521-4b0c-beb3-9fde312884bb\", latitude: Some(31.74995), longitude: Some(-106.30986) }" ] + 15 [ label = "Site { name: \"FescuePoP\", id: \"d1e84fab-f69d-469c-9845-7de9a105afc2\", latitude: Some(31.92754), longitude: Some(-106.62176) }" ] + 16 [ label = "Site { name: \"TrailerCity\", id: \"651b2e8a-be7a-462e-8be9-fefc15bfaa66\", latitude: Some(31.81881), longitude: Some(-106.66134) }" ] + 17 [ label = "Site { name: \"Medano Heights Apt 9\", id: \"b95c4e1b-8493-4ea2-851a-08acdee4eae3\", latitude: Some(31.869942), longitude: Some(-106.57515) }" ] + 18 [ label = "Site { name: \"6360 Gato Rd PoP\", id: \"d4500ad8-18d5-429d-bf20-c9cf00e4bf33\", latitude: Some(31.887419), longitude: Some(-106.63829) }" ] + 19 [ label = "Site { name: \"7280 Westside Rd\", id: \"9c305b81-d273-4ed5-ae91-7ca83ef78799\", latitude: Some(31.922024), longitude: Some(-106.6224) }" ] + 20 [ label = "Site { name: \"Oro Onions PoP\", id: \"4cfd270c-ec3c-4df8-8cf0-4a3031adb26b\", latitude: Some(31.85667), longitude: Some(-106.709366) }" ] + 21 [ label = "Site { name: \"Medano Heights Apt 8\", id: \"5ace14aa-a838-4d1c-abf8-53bd96b1816c\", latitude: Some(31.87055), longitude: Some(-106.57533) }" ] + 22 [ label = "Site { name: \"Medano Heights Apt 5\", id: \"ed0b9430-71c4-495a-81dc-3f09e00fd8cd\", latitude: Some(31.870543), longitude: Some(-106.576645) }" ] + 23 [ label = "Site { name: \"412 Bellwoode\", id: \"8763cb6a-1e11-4038-b58f-d2a1b11845f2\", latitude: Some(31.890833), longitude: Some(-106.58559) }" ] + 24 [ label = "Site { name: \"Appaloosa PoP\", id: \"e366b7ce-4779-470c-9edc-40413a8375a1\", latitude: Some(31.82079), longitude: Some(-106.591515) }" ] + 25 [ label = "Site { name: \"Valley Acres\", id: \"dd8cf5ac-90e4-4032-8749-f342058344e4\", latitude: Some(31.929832), longitude: Some(-106.62389) }" ] + 26 [ label = "Site { name: \"Canutillo\", id: \"30a1f385-eef2-417b-b4ef-ce98824c6fb6\", latitude: Some(31.893366), longitude: Some(-106.5853) }" ] + 27 [ label = "Site { name: \"Santiago\", id: \"0d6d0350-474a-4b64-b444-ff7a100d07c3\", latitude: Some(31.94448), longitude: Some(-106.593475) }" ] + 28 [ label = "Site { name: \"1395 Koogle PoP\", id: \"29472928-45a8-4bea-b65d-60c8760f4753\", latitude: Some(31.908194), longitude: Some(-106.67889) }" ] + 29 [ label = "Site { name: \"Medano Heights Apt 3\", id: \"140ab861-d12f-4e71-96ba-2789721891b5\", latitude: Some(31.869995), longitude: Some(-106.57619) }" ] + 30 [ label = "Site { name: \"Medano Heights Apt 2\", id: \"579ae80d-0cff-471e-bb49-e80c651b0132\", latitude: Some(31.869286), longitude: Some(-106.57539) }" ] + 31 [ label = "Site { name: \"Admiral\", id: \"25b9445f-a8d3-45ec-976f-24550e198280\", latitude: Some(31.850914), longitude: Some(-106.70479) }" ] + 32 [ label = "Site { name: \"Medano Heights Apt 6\", id: \"12d9f9fe-3445-4ba5-9389-4ac8b10e5a9d\", latitude: Some(31.870625), longitude: Some(-106.575905) }" ] + 33 [ label = "Site { name: \"2049 Boy Scout\", id: \"8ebc8e2e-6117-40cb-9da9-948f69f6d825\", latitude: Some(31.808477), longitude: Some(-106.57116) }" ] + 34 [ label = "Site { name: \"WestRedd Tower\", id: \"54314e0f-a30a-40f5-af79-5a10091fd0a1\", latitude: Some(31.861029), longitude: Some(-106.54946) }" ] + 35 [ label = "Site { name: \"Butterfield\", id: \"665e71f2-4d07-4c5b-8457-6aaf92655857\", latitude: Some(31.823368), longitude: Some(-106.33805) }" ] + 36 [ label = "Site { name: \"2800 Airport Road\", id: \"774f79c6-1356-48cf-b324-0a2bd67a9d7a\", latitude: Some(31.869122), longitude: Some(-106.69814) }" ] + 37 [ label = "Site { name: \"Monte Del Sol\", id: \"30f74137-a59d-494f-b5b3-bee0877e539c\", latitude: Some(31.881372), longitude: Some(-106.55896) }" ] + 38 [ label = "Site { name: \"MRE\", id: \"8acf2b30-b104-4d62-87cb-b57f861fd1c6\", latitude: Some(31.815521), longitude: Some(-106.48929) }" ] + 39 [ label = "Site { name: \"7232 Rochester\", id: \"1652b68c-0908-4f5a-b9d6-4631dea26b67\", latitude: Some(31.854183), longitude: Some(-106.55559) }" ] + 40 [ label = "Site { name: \"Sun Plaza\", id: \"2666860f-5b0e-4124-86dd-75b11a49e134\", latitude: Some(31.762056), longitude: Some(-106.47605) }" ] + 41 [ label = "AccessPoint { name: \"NanoSwitch-Girlscout\", id: \"84317fc8-74c3-47cc-a8f1-adb57fad4b75\", site_name: \"2801 Girl Scout Lane\", download_mbps: 1000, upload_mbps: 1000 }" ] + 42 [ label = "AccessPoint { name: \"MikroTik-WestRedd-ALERT\", id: \"fffa0f89-0b6c-4ddf-a6c7-a036307de161\", site_name: \"WestRedd\", download_mbps: 5000, upload_mbps: 5000 }" ] + 43 [ label = "AccessPoint { name: \"JackRabbit_T\", id: \"5f8f20e7-b88f-449f-bd0d-cb0c028196b3\", site_name: \"7232 Rochester\", download_mbps: 328, upload_mbps: 71 }" ] + 44 [ label = "AccessPoint { name: \"EPMPF400C_LaDobeToMonte\", id: \"8664884f-14c2-4253-962a-040338a4ad0e\", site_name: \"LaDobe\", download_mbps: 1000, upload_mbps: 1000 }" ] + 45 [ label = "AccessPoint { name: \"LAP-1315LosPinos_SE\", id: \"2d972772-6e78-4353-a680-8882b12f1582\", site_name: \"Vinton\", download_mbps: 342, upload_mbps: 344 }" ] + 46 [ label = "AccessPoint { name: \"SunPlaza-Floor4-Switch\", id: \"b9ede9b4-8dd6-4f46-878a-16ff7449bbed\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 47 [ label = "AccessPoint { name: \"Rochester-Roof-Switch\", id: \"ccae9b06-284b-4e39-865a-22ae83789e0c\", site_name: \"7232 Rochester\", download_mbps: 5000, upload_mbps: 5000 }" ] + 48 [ label = "AccessPoint { name: \"JackRabbit_D\", id: \"9f3219a4-1f64-4c7e-80ba-37a37ecd7607\", site_name: \"WestRedd Tower\", download_mbps: 180, upload_mbps: 50 }" ] + 49 [ label = "AccessPoint { name: \"F425_Monte_to_LaDobe\", id: \"5d5dcfb3-254a-42ae-8480-8a3ee10ce61c\", site_name: \"Monte Del Sol\", download_mbps: 1000, upload_mbps: 1000 }" ] + 50 [ label = "AccessPoint { name: \"Rochester PacketFlux RackInjector\", id: \"049f9009-78ed-40e6-bbfa-999546098a13\", site_name: \"7232 Rochester\", download_mbps: 5000, upload_mbps: 5000 }" ] + 51 [ label = "AccessPoint { name: \"NanoSwitch-1308DesiertoSeco\", id: \"d0447259-db26-4ce7-9f8f-58d915d66d17\", site_name: \"1308 Desierto Seco PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 52 [ label = "AccessPoint { name: \"FiberBox-MRE-2\", id: \"bf76a744-55ff-4a91-ae31-6691d47d657b\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 53 [ label = "AccessPoint { name: \"Core-SDWAN\", id: \"bcbac668-c34c-4729-acdc-7f9db429d480\", site_name: \"MDC El Paso\", download_mbps: 100, upload_mbps: 50 }" ] + 54 [ label = "AccessPoint { name: \"Macarena Site Router\", id: \"00ca3a21-88b7-4896-8f6e-2de42ecd89d1\", site_name: \"Macarena\", download_mbps: 5000, upload_mbps: 5000 }" ] + 55 [ label = "AccessPoint { name: \"4600C_MRE_To_ROCH\", id: \"73805387-414a-4e67-8302-9c4add6fd2bc\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 56 [ label = "AccessPoint { name: \"4600C_ROCH_To_MRE\", id: \"2f216b07-6379-4221-abf2-79ab6fdde445\", site_name: \"7232 Rochester\", download_mbps: 5000, upload_mbps: 5000 }" ] + 57 [ label = "AccessPoint { name: \"NanoSwitch-2832Girlscout\", id: \"2ae6d489-2ee9-477e-9fc0-17b423b70a1a\", site_name: \"2832 Girlscout Lane PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 58 [ label = "AccessPoint { name: \"Rochester Switch\", id: \"f26c25b7-72d5-46ee-ba58-8aa9a2e7c2fd\", site_name: \"7232 Rochester\", download_mbps: 5000, upload_mbps: 5000 }" ] + 59 [ label = "AccessPoint { name: \"MT-7280WestsideRd\", id: \"149947bc-a040-4c26-8533-e945a47f6fab\", site_name: \"7280 Westside Rd\", download_mbps: 300, upload_mbps: 30 }" ] + 60 [ label = "AccessPoint { name: \"PowerBoxPro-Fescue\", id: \"a784adfb-9a67-4f06-a30f-fcdd5127579a\", site_name: \"FescuePoP\", download_mbps: 5000, upload_mbps: 5000 }" ] + 61 [ label = "AccessPoint { name: \"PowerBoxPro-1395KooglePoP\", id: \"eeab0d04-a0e4-40f8-9431-b92d6b30be02\", site_name: \"1395 Koogle PoP\", download_mbps: 5000, upload_mbps: 5000 }" ] + 62 [ label = "AccessPoint { name: \"SunPlaza-Floor7-Switch\", id: \"1624c9de-4d06-4f18-8d23-b6f4dc1386ad\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 63 [ label = "AccessPoint { name: \"JR_AP_KH_A\", id: \"6b5e7b1c-d9fb-4ad8-a2a8-ba53e76cde31\", site_name: \"175 Kittyhawk\", download_mbps: 262, upload_mbps: 126 }" ] + 64 [ label = "AccessPoint { name: \"PowerBoxPro-Macarena\", id: \"31b81659-7697-4296-82a9-415b51a7001f\", site_name: \"Macarena\", download_mbps: 5000, upload_mbps: 5000 }" ] + 65 [ label = "AccessPoint { name: \"Router-Vinton\", id: \"d3172daa-ce0c-4f1e-9830-a47c4a3a3594\", site_name: \"Vinton\", download_mbps: 5000, upload_mbps: 5000 }" ] + 66 [ label = "AccessPoint { name: \"MRE Rack 10G Switch\", id: \"0c030ff3-aacd-46eb-8b7a-34d647a9b406\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 67 [ label = "AccessPoint { name: \"Switch-412Bellwoode-PoP\", id: \"2b51527f-0f47-43d3-9ab8-5ca25476d03b\", site_name: \"412 Bellwoode\", download_mbps: 1000, upload_mbps: 1000 }" ] + 68 [ label = "AccessPoint { name: \"JackRabbit_A\", id: \"ed942bdd-2e23-47c6-8ce4-3cc94f6ad900\", site_name: \"WestRedd Tower\", download_mbps: 177, upload_mbps: 176 }" ] + 69 [ label = "AccessPoint { name: \"NetBox-TuscanyToAdmiral\", id: \"3813b609-b432-41e4-a273-e7151c91b3bc\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 70 [ label = "AccessPoint { name: \"LBE-1300NVintonUnit3\", id: \"ca7e9d04-14ba-42e7-914f-394323e9d32a\", site_name: \"1300NVintonUnits3and4\", download_mbps: 222, upload_mbps: 224 }" ] + 71 [ label = "AccessPoint { name: \"LBE-2832Girlscout\", id: \"41eeee89-1c08-4673-bcdd-3723fd29d340\", site_name: \"2832 Girlscout Lane PoP\", download_mbps: 200, upload_mbps: 207 }" ] + 72 [ label = "AccessPoint { name: \"LTU-4005Appaloosa\", id: \"b9421fd2-5fdb-4c89-9cac-52887a251a6c\", site_name: \"Appaloosa PoP\", download_mbps: 200, upload_mbps: 56 }" ] + 73 [ label = "AccessPoint { name: \"Core-WestRedd\", id: \"f54506dd-79a9-4f41-adab-58f7d07de1ad\", site_name: \"MDC El Paso\", download_mbps: 5000, upload_mbps: 5000 }" ] + 74 [ label = "AccessPoint { name: \"SunPlaza-Floor6-Switch\", id: \"31aeb012-928a-47d9-b435-073fa30a85ed\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 75 [ label = "AccessPoint { name: \"JR_AP_MRE_D\", id: \"2763a7cc-a433-46bd-98a4-2bbfe3aea37f\", site_name: \"MRE\", download_mbps: 949, upload_mbps: 949 }" ] + 76 [ label = "AccessPoint { name: \"MRE-SiteRouter\", id: \"897abef1-bf51-4bbd-8076-004f3c440f1c\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 77 [ label = "AccessPoint { name: \"JackRabbit_B\", id: \"ccd3e418-0a5b-48a4-8998-c81122b2bc52\", site_name: \"WestRedd Tower\", download_mbps: 172, upload_mbps: 106 }" ] + 78 [ label = "AccessPoint { name: \"NetPower16P-OroOnionsPoP\", id: \"2a10bc2e-fe1f-4eeb-8dc2-e6979af11e87\", site_name: \"Oro Onions PoP\", download_mbps: 5000, upload_mbps: 5000 }" ] + 79 [ label = "AccessPoint { name: \"LTU-412Bellwoode-AP\", id: \"9033429c-98f9-46ff-a534-01fcd4230c37\", site_name: \"412 Bellwoode\", download_mbps: 254, upload_mbps: 81 }" ] + 80 [ label = "AccessPoint { name: \"JR_ST_AP_5_180_90\", id: \"079e3abf-5ad7-48aa-9dcf-4507892fabbc\", site_name: \"Santiago\", download_mbps: 273, upload_mbps: 327 }" ] + 81 [ label = "AccessPoint { name: \"JR_AP_MRE_E\", id: \"d37bf63b-2d13-4bfc-92e1-371c79ebf917\", site_name: \"MRE\", download_mbps: 195, upload_mbps: 45 }" ] + 82 [ label = "AccessPoint { name: \"JR_AP_TR_C\", id: \"3515e4da-6666-4965-b909-ea94e56c74e1\", site_name: \"Tuscany Ridge\", download_mbps: 300, upload_mbps: 70 }" ] + 83 [ label = "AccessPoint { name: \"JackRabbit_L\", id: \"7569ece2-008f-4bc5-8933-ac6a29fb8d6d\", site_name: \"WestRedd Tower\", download_mbps: 188, upload_mbps: 167 }" ] + 84 [ label = "AccessPoint { name: \"JackRabbit_G\", id: \"07479df5-2676-42c0-820d-a45292b92c93\", site_name: \"WestRedd Tower\", download_mbps: 129, upload_mbps: 80 }" ] + 85 [ label = "AccessPoint { name: \"FiberBox-OroOnionsPoP\", id: \"1f02e8bd-8e7a-4e63-bf92-64ddd2d44b1c\", site_name: \"Oro Onions PoP\", download_mbps: 5000, upload_mbps: 5000 }" ] + 86 [ label = "AccessPoint { name: \"WestRedd-SiteRouter\", id: \"2fc7c936-a4be-42bc-9f96-fdc22c23a5d6\", site_name: \"WestRedd\", download_mbps: 5000, upload_mbps: 5000 }" ] + 87 [ label = "AccessPoint { name: \"SunPlaza-SiteRouter\", id: \"4999ae48-fde1-427a-ba9b-10b248a7864f\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 88 [ label = "AccessPoint { name: \"JR_AP_TR_B\", id: \"e02f6883-ebdb-4e15-a4a0-512e2b5f0883\", site_name: \"Tuscany Ridge\", download_mbps: 287, upload_mbps: 59 }" ] + 89 [ label = "AccessPoint { name: \"Kittyhawk_to_2660\", id: \"919f00f8-5703-4467-aab5-88f361fcee84\", site_name: \"175 Kittyhawk\", download_mbps: 5000, upload_mbps: 5000 }" ] + 90 [ label = "AccessPoint { name: \"LiteAP-LaDobe\", id: \"d8c1b0b7-7121-401d-a54d-705141cd7f15\", site_name: \"190 LaDobe PoP\", download_mbps: 148, upload_mbps: 124 }" ] + 91 [ label = "AccessPoint { name: \"SunPlaza-Floor8-Switch\", id: \"13565d97-3f59-46fb-b0e4-dbbabf69a8b2\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 92 [ label = "AccessPoint { name: \"JR_AP_LD_B\", id: \"0339c9bc-a53b-4cb4-8259-c64d066669c9\", site_name: \"LaDobe\", download_mbps: 325, upload_mbps: 61 }" ] + 93 [ label = "AccessPoint { name: \"JackRabbit_H\", id: \"bb1cca36-5bbb-4061-8dd2-1b87e9a9d1bf\", site_name: \"WestRedd Tower\", download_mbps: 225, upload_mbps: 50 }" ] + 94 [ label = "AccessPoint { name: \"LBE-BoyScoutPoP\", id: \"04fd7619-9cc6-492f-98a8-cb2c407609a8\", site_name: \"2049 Boy Scout\", download_mbps: 400, upload_mbps: 425 }" ] + 95 [ label = "AccessPoint { name: \"JackRabbit_E\", id: \"4f2464b1-3d08-4d39-8be0-42c0eeb95f69\", site_name: \"WestRedd Tower\", download_mbps: 225, upload_mbps: 50 }" ] + 96 [ label = "AccessPoint { name: \"WestwindRedd_EdgePoint_1\", id: \"0b68ff0b-1411-405c-8662-fa5c955afe11\", site_name: \"WestRedd Tower\", download_mbps: 5000, upload_mbps: 5000 }" ] + 97 [ label = "AccessPoint { name: \"PowerBoxPro-GatoPoP\", id: \"6e444858-14f3-406d-ba09-0912e782d623\", site_name: \"6360 Gato Rd PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 98 [ label = "AccessPoint { name: \"LTU-2801GirlScoutLane\", id: \"1a76bc06-03f3-4391-9b45-826adead27e8\", site_name: \"2801 Girl Scout Lane\", download_mbps: 330, upload_mbps: 71 }" ] + 99 [ label = "AccessPoint { name: \"JR_AP_CT_A\", id: \"af8cae80-1790-4063-afc2-6f82cd212800\", site_name: \"Canutillo\", download_mbps: 283, upload_mbps: 87 }" ] + 100 [ label = "AccessPoint { name: \"LBE-Vinton_NE\", id: \"2715e127-41b8-4236-a3e0-f74d20448ffd\", site_name: \"Vinton\", download_mbps: 3249, upload_mbps: 3249 }" ] + 101 [ label = "AccessPoint { name: \"SiteRouter-Admiral\", id: \"a22b6075-9f6e-47e7-b734-f2c829109a03\", site_name: \"Admiral\", download_mbps: 5000, upload_mbps: 5000 }" ] + 102 [ label = "AccessPoint { name: \"ICT_UPS_MRE\", id: \"bb8bd4b4-1be7-45cb-a01f-3f7889b1ccc5\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 103 [ label = "AccessPoint { name: \"LTU-ValleyAcres\", id: \"a1ae64fc-79c0-45f8-b1d7-e884b9160e4c\", site_name: \"Valley Acres\", download_mbps: 198, upload_mbps: 37 }" ] + 104 [ label = "AccessPoint { name: \"LTU-2049BoyScout\", id: \"5c261c61-c27e-4208-919a-a1aa2d4808b9\", site_name: \"2049 Boy Scout\", download_mbps: 330, upload_mbps: 94 }" ] + 105 [ label = "AccessPoint { name: \"NetPower16P-AppaloosaPoP\", id: \"422b3852-6d49-4847-8873-915707450c8b\", site_name: \"Appaloosa PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 106 [ label = "AccessPoint { name: \"JackRabbit_C\", id: \"a9347915-677e-4115-8938-06d6a7a931e2\", site_name: \"WestRedd Tower\", download_mbps: 211, upload_mbps: 189 }" ] + 107 [ label = "AccessPoint { name: \"LTU-700Fescue\", id: \"e7c3b629-ea2b-41f2-aec1-29eb4ea8ccf6\", site_name: \"FescuePoP\", download_mbps: 276, upload_mbps: 75 }" ] + 108 [ label = "AccessPoint { name: \"JR_AP_CT_D\", id: \"d9f0cedc-98aa-46b6-bbbe-9d8886f4cb4d\", site_name: \"Canutillo\", download_mbps: 300, upload_mbps: 50 }" ] + 109 [ label = "AccessPoint { name: \"SunPlaza-Floor3-Switch\", id: \"6892544c-2586-4eb9-905a-141274dee1a4\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 110 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt7_To_Apt9\", id: \"67346c82-3093-4c19-bf11-414ba2092dfc\", site_name: \"Medano Heights Apt 7\", download_mbps: 1617, upload_mbps: 1617 }" ] + 111 [ label = "AccessPoint { name: \"JR_AP_MRE_C\", id: \"4168d03a-013d-404d-9e10-2c49f8a8c6ff\", site_name: \"MRE\", download_mbps: 230, upload_mbps: 92 }" ] + 112 [ label = "AccessPoint { name: \"EdgePoint-MRE-1\", id: \"5a13cb64-a4a0-4300-8550-bd0edca52914\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 113 [ label = "AccessPoint { name: \"GBE-1308DesiertoSeco\", id: \"0ea20e1a-e1ce-4487-8218-89f5301c5eda\", site_name: \"1308 Desierto Seco PoP\", download_mbps: 1395, upload_mbps: 1617 }" ] + 114 [ label = "AccessPoint { name: \"JR_AP_TR_KG\", id: \"bad4dad0-e250-4844-908d-9977a2704ec0\", site_name: \"Tuscany Ridge\", download_mbps: 193, upload_mbps: 56 }" ] + 115 [ label = "AccessPoint { name: \"JackRabbit_N\", id: \"76e6f502-8d6c-44e9-b978-baff24d9f434\", site_name: \"7232 Rochester\", download_mbps: 258, upload_mbps: 58 }" ] + 116 [ label = "AccessPoint { name: \"airCube-LaDobeAlert\", id: \"768429d7-7903-4360-8d01-ac649ced45e0\", site_name: \"LaDobe\", download_mbps: 5000, upload_mbps: 5000 }" ] + 117 [ label = "AccessPoint { name: \"FiberBox-MRE-1\", id: \"dd6dc666-e2f0-49cd-8a29-a8ceddc5d1fd\", site_name: \"MRE\", download_mbps: 10000, upload_mbps: 10000 }" ] + 118 [ label = "AccessPoint { name: \"Enchanted (Site Router)\", id: \"3ab15580-d162-42ac-9b18-094d8d7bbd9c\", site_name: \"Enchanted\", download_mbps: 5000, upload_mbps: 5000 }" ] + 119 [ label = "AccessPoint { name: \"LaDobe Camera\", id: \"f4cf7dc0-8d5b-41f0-bbe8-cce4dc49cb48\", site_name: \"LaDobe\", download_mbps: 5000, upload_mbps: 5000 }" ] + 120 [ label = "AccessPoint { name: \"TrailerCity\", id: \"201b7b84-e5fe-46e7-b43f-3973ae5a3922\", site_name: \"TrailerCity\", download_mbps: 1200, upload_mbps: 1200 }" ] + 121 [ label = "AccessPoint { name: \"SunPlaza-Floor2-Switch\", id: \"42f80d04-3a7a-4d88-869b-0f224b17f932\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 122 [ label = "AccessPoint { name: \"JR_AP_MT_60_A\", id: \"5184f200-d42c-4f48-b6f3-623b903cc1ab\", site_name: \"Monte Del Sol\", download_mbps: 1000, upload_mbps: 1000 }" ] + 123 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt4_To_Apt6\", id: \"012e3874-71f9-4a0b-b696-c59c72119082\", site_name: \"Medano Heights Apt 4\", download_mbps: 1617, upload_mbps: 1617 }" ] + 124 [ label = "AccessPoint { name: \"SunPlaza-Floor5-Switch\", id: \"dfb201f6-0159-4642-b69a-07b9ca8a459a\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 125 [ label = "AccessPoint { name: \"JackRabbit_P\", id: \"cbead119-b8b4-4fe1-9360-b3a9a582acc7\", site_name: \"7232 Rochester\", download_mbps: 298, upload_mbps: 70 }" ] + 126 [ label = "AccessPoint { name: \"JR_AP_TR_WV1\", id: \"020b432d-0563-4bb2-9db6-e17f4de86fdf\", site_name: \"Tuscany Ridge\", download_mbps: 950, upload_mbps: 950 }" ] + 127 [ label = "AccessPoint { name: \"JackRabbit_O\", id: \"22b8d93a-fe70-46f1-981e-a4861d2b4846\", site_name: \"7232 Rochester\", download_mbps: 290, upload_mbps: 73 }" ] + 128 [ label = "AccessPoint { name: \"airCube-TuscanyRidgeAlert\", id: \"c913f8f1-a86a-41ec-ba81-3a7d4d83fffe\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 129 [ label = "AccessPoint { name: \"JR_AP_TC_C\", id: \"85532523-e400-430b-941a-d57858bba471\", site_name: \"TrailerCity\", download_mbps: 5000, upload_mbps: 5000 }" ] + 130 [ label = "AccessPoint { name: \"JR_AP_CT_B\", id: \"b238866b-b082-4a0a-ab0b-63fbe2021678\", site_name: \"Canutillo\", download_mbps: 319, upload_mbps: 83 }" ] + 131 [ label = "AccessPoint { name: \"JackRabbit_K\", id: \"a04ba682-4391-4512-8019-19d7bef23424\", site_name: \"WestRedd Tower\", download_mbps: 3249, upload_mbps: 3249 }" ] + 132 [ label = "AccessPoint { name: \"JR_AP_RR_B\", id: \"a4a2bdc4-e135-44d8-bbd1-03d72e43fddb\", site_name: \"Appaloosa PoP\", download_mbps: 400, upload_mbps: 400 }" ] + 133 [ label = "AccessPoint { name: \"UBNT EdgeSwitch\", id: \"485133df-fc31-4026-99d5-f34244a66314\", site_name: \"WestRedd Tower\", download_mbps: 5000, upload_mbps: 5000 }" ] + 134 [ label = "AccessPoint { name: \"PowerBoxPro-BoyScout\", id: \"481bad9d-7f8b-4300-b6a3-d1d4c09f3949\", site_name: \"2049 Boy Scout\", download_mbps: 1000, upload_mbps: 1000 }" ] + 135 [ label = "AccessPoint { name: \"JR_AP_TR_E\", id: \"ad6595e3-7dbf-407e-a998-dc46d53b6c9f\", site_name: \"Tuscany Ridge\", download_mbps: 127, upload_mbps: 108 }" ] + 136 [ label = "AccessPoint { name: \"N7R-1300NVintonUnits3and4\", id: \"9afdf86d-fd3a-46f5-baca-bf7b7867411b\", site_name: \"1300NVintonUnits3and4\", download_mbps: 1000, upload_mbps: 1000 }" ] + 137 [ label = "AccessPoint { name: \"SunPlaza-Floor9-Switch\", id: \"3736dae5-3c16-4963-89a1-38c660adf88a\", site_name: \"Sun Plaza\", download_mbps: 5000, upload_mbps: 5000 }" ] + 138 [ label = "AccessPoint { name: \"EPMPF400C_CanutilloToLaDobe\", id: \"dc0c1f26-4fcc-4f8e-a996-fc7bf9a50c98\", site_name: \"Canutillo\", download_mbps: 5000, upload_mbps: 5000 }" ] + 139 [ label = "AccessPoint { name: \"MedanoHeights_Apt8_EdgeSwitch18X\", id: \"36defd70-6a2a-4564-8894-655a4d3eea2b\", site_name: \"Medano Heights Apt 8\", download_mbps: 5000, upload_mbps: 5000 }" ] + 140 [ label = "AccessPoint { name: \"NetPower16P-MRE\", id: \"deec1dd6-6995-4422-b3b7-111ccfa669cb\", site_name: \"MRE\", download_mbps: 10000, upload_mbps: 10000 }" ] + 141 [ label = "AccessPoint { name: \"PTP_700Fescue_House\", id: \"3d816fd8-e05a-4849-86d5-b470b910b88d\", site_name: \"FescuePoP\", download_mbps: 191, upload_mbps: 193 }" ] + 142 [ label = "AccessPoint { name: \"EdgeSwitch-TuscanyRidge\", id: \"0e1ad159-25da-4cea-b2bb-df15462a3770\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 143 [ label = "AccessPoint { name: \"EdgeSwitch-TuscanyRidge\", id: \"0e1ad159-25da-4cea-b2bb-df15462a3770\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 144 [ label = "AccessPoint { name: \"EdgeSwitch-TuscanyRidge\", id: \"0e1ad159-25da-4cea-b2bb-df15462a3770\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 145 [ label = "AccessPoint { name: \"EdgeSwitch-TuscanyRidge\", id: \"0e1ad159-25da-4cea-b2bb-df15462a3770\", site_name: \"Tuscany Ridge\", download_mbps: 5000, upload_mbps: 5000 }" ] + 146 [ label = "AccessPoint { name: \"JackRabbit_M\", id: \"767762fe-b693-4ecf-b909-fec441433079\", site_name: \"7232 Rochester\", download_mbps: 345, upload_mbps: 64 }" ] + 147 [ label = "AccessPoint { name: \"MRE NVR (Power Outage Alert)\", id: \"6e73730c-8adf-4f6d-bf99-1ca890046304\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 148 [ label = "AccessPoint { name: \"MedanoHeights_Apt9_EdgeSwitch18X\", id: \"d4dff542-acaf-4b9d-8a7f-6672e3c3ca5e\", site_name: \"Medano Heights Apt 9\", download_mbps: 5000, upload_mbps: 5000 }" ] + 149 [ label = "AccessPoint { name: \"LTU-190LadobeCt\", id: \"d9a4db03-37b8-4342-86cc-1b59d14ff746\", site_name: \"190 LaDobe PoP\", download_mbps: 131, upload_mbps: 36 }" ] + 150 [ label = "AccessPoint { name: \"OroOnionsPoP-SiteRouter\", id: \"b4a3dbc0-3ea5-4350-afe2-3c3de9605592\", site_name: \"Oro Onions PoP\", download_mbps: 5000, upload_mbps: 5000 }" ] + 151 [ label = "AccessPoint { name: \"LTU-412BellwoodeAve\", id: \"ffbbbdf8-0811-4e17-a9af-f01ae6189092\", site_name: \"412 Bellwoode\", download_mbps: 340, upload_mbps: 71 }" ] + 152 [ label = "AccessPoint { name: \"JackRabbit_Q\", id: \"e6521f7b-1878-4aff-b16b-772e68e904b5\", site_name: \"7232 Rochester\", download_mbps: 322, upload_mbps: 63 }" ] + 153 [ label = "AccessPoint { name: \"JR_AP_CT_C\", id: \"f9116834-d3ff-4d2e-b3aa-bb570c8e6991\", site_name: \"Canutillo\", download_mbps: 322, upload_mbps: 80 }" ] + 154 [ label = "AccessPoint { name: \"JR_AP_EC_A\", id: \"32cc8c5e-95c9-4762-a6be-08d7c1125647\", site_name: \"Enchanted\", download_mbps: 1000, upload_mbps: 1000 }" ] + 155 [ label = "AccessPoint { name: \"WestwindRedd_EdgePoint_4\", id: \"575c3da4-c9d7-46a5-8fe7-1f8dc8c85c4a\", site_name: \"WestRedd Tower\", download_mbps: 5000, upload_mbps: 5000 }" ] + 156 [ label = "AccessPoint { name: \"WavePro-MRE-To-ClayCo-Connex\", id: \"9f857607-080b-40aa-b099-1a833ba667e5\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 157 [ label = "AccessPoint { name: \"NetPower16P-429Sonaran\", id: \"e97caedb-30ca-4f27-b15e-a43c0b59238c\", site_name: \"Canutillo\", download_mbps: 5000, upload_mbps: 5000 }" ] + 158 [ label = "AccessPoint { name: \"JR_AP_LD_C\", id: \"4f65e8d3-dee7-49c8-8691-1dbd7cc7e8e9\", site_name: \"LaDobe\", download_mbps: 255, upload_mbps: 56 }" ] + 159 [ label = "AccessPoint { name: \"NP16-Santiago\", id: \"3561754c-5355-439b-bd60-6753f91bae0d\", site_name: \"Santiago\", download_mbps: 5000, upload_mbps: 5000 }" ] + 160 [ label = "AccessPoint { name: \"JackRabbit-Rochester-MikroTik\", id: \"21edb52c-5aeb-42b3-a28f-8abc22f5829a\", site_name: \"7232 Rochester\", download_mbps: 5000, upload_mbps: 5000 }" ] + 161 [ label = "AccessPoint { name: \"JR_AP_LD_D\", id: \"5a741571-255f-4413-ad88-c7191de13721\", site_name: \"LaDobe\", download_mbps: 226, upload_mbps: 40 }" ] + 162 [ label = "AccessPoint { name: \"LTU-175Kittyhawk\", id: \"0322c3f7-3758-4cc2-a15d-e4f92fce3ca6\", site_name: \"175 Kittyhawk\", download_mbps: 134, upload_mbps: 129 }" ] + 163 [ label = "AccessPoint { name: \"JR_AP_TR_D\", id: \"3d1909fc-e660-43af-8e1b-aedc124bd85f\", site_name: \"Tuscany Ridge\", download_mbps: 338, upload_mbps: 84 }" ] + 164 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt6_To_Apt4\", id: \"426f33ae-ec7d-4966-9b57-2ffe9f7f056f\", site_name: \"Medano Heights Apt 6\", download_mbps: 1617, upload_mbps: 1617 }" ] + 165 [ label = "AccessPoint { name: \"NP16P-Kittyhawk\", id: \"18a7d6e3-3402-4ea9-ab6f-570d179d6574\", site_name: \"175 Kittyhawk\", download_mbps: 1000, upload_mbps: 1000 }" ] + 166 [ label = "AccessPoint { name: \"MDC-Edge-A\", id: \"9543fa9b-93e9-4332-a135-78f6f774a169\", site_name: \"MDC El Paso\", download_mbps: 5000, upload_mbps: 5000 }" ] + 167 [ label = "AccessPoint { name: \"NetPower16P-Enchanted\", id: \"431cfe17-054f-47de-a033-040634ae6c26\", site_name: \"Enchanted\", download_mbps: 5000, upload_mbps: 5000 }" ] + 168 [ label = "AccessPoint { name: \"JackRabbit_I\", id: \"57c26f68-3f22-4d0a-89ca-1f9cd91a2411\", site_name: \"WestRedd Tower\", download_mbps: 3249, upload_mbps: 3249 }" ] + 169 [ label = "AccessPoint { name: \"JR_AP_RR_A\", id: \"04fbd5f8-e2f3-4a04-a5b3-ae53c7e84ff4\", site_name: \"Appaloosa PoP\", download_mbps: 100, upload_mbps: 100 }" ] + 170 [ label = "AccessPoint { name: \"Switch-ValleyAcres\", id: \"32494fb8-e267-4a08-9e3f-43ec9477f6ba\", site_name: \"Valley Acres\", download_mbps: 1000, upload_mbps: 1000 }" ] + 171 [ label = "AccessPoint { name: \"EdgePoint-LaDobe\", id: \"1a1faa7f-b991-45c2-b298-661b408371c4\", site_name: \"LaDobe\", download_mbps: 5000, upload_mbps: 5000 }" ] + 172 [ label = "AccessPoint { name: \"JR_AP_MRE_A\", id: \"c6618db7-f463-43ce-a46a-41de152c4abf\", site_name: \"MRE\", download_mbps: 219, upload_mbps: 68 }" ] + 173 [ label = "AccessPoint { name: \"EPMPF400C-LaDobeToCanutillo\", id: \"993d2708-0d42-4470-aea6-436483274e69\", site_name: \"LaDobe\", download_mbps: 1000, upload_mbps: 1000 }" ] + 174 [ label = "AccessPoint { name: \"LCO-2832Girlscout\", id: \"e3ba0f3a-cddc-477e-92a1-902bb1a6f67b\", site_name: \"2832 Girlscout Lane PoP\", download_mbps: 198, upload_mbps: 220 }" ] + 175 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt3_To_Apt5\", id: \"1b058361-bf18-42d3-8a2d-0ad34e6bbd26\", site_name: \"Medano Heights Apt 3\", download_mbps: 5000, upload_mbps: 5000 }" ] + 176 [ label = "AccessPoint { name: \"Loco_1395Koogle_To_RV\", id: \"5d6b6101-8347-4e0c-ba92-4ee94d101242\", site_name: \"1395 Koogle PoP\", download_mbps: 83, upload_mbps: 109 }" ] + 177 [ label = "AccessPoint { name: \"PowerBoxPro-LaDobe\", id: \"5eccb6e9-0d3d-42b2-8348-f406e36b3367\", site_name: \"190 LaDobe PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 178 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt9_To_Apt7\", id: \"56e5c8bc-be36-4ab4-8fc5-9a184a0fda15\", site_name: \"Medano Heights Apt 9\", download_mbps: 1409, upload_mbps: 1617 }" ] + 179 [ label = "AccessPoint { name: \"JR_AP_TC_A\", id: \"d10d7456-c3df-4b50-a4d9-5596dc7619c1\", site_name: \"TrailerCity\", download_mbps: 5000, upload_mbps: 5000 }" ] + 180 [ label = "AccessPoint { name: \"JackRabbit_S\", id: \"d39127a6-0cfe-43d3-bc47-441faa98e9f2\", site_name: \"7232 Rochester\", download_mbps: 303, upload_mbps: 60 }" ] + 181 [ label = "AccessPoint { name: \"JR_AP_MRE_B\", id: \"906eadcb-45fd-4e1e-8cb9-bb4efab1f79f\", site_name: \"MRE\", download_mbps: 291, upload_mbps: 73 }" ] + 182 [ label = "AccessPoint { name: \"JR_AP_TR_G\", id: \"95a1b763-d7d2-4d79-ac97-7f77e83a8441\", site_name: \"Tuscany Ridge\", download_mbps: 227, upload_mbps: 57 }" ] + 183 [ label = "AccessPoint { name: \"MedanoHeights_Apt6_EdgeSwitch18X\", id: \"f95af296-d004-4a0a-aa2d-f19dd125ecc7\", site_name: \"Medano Heights Apt 6\", download_mbps: 5000, upload_mbps: 5000 }" ] + 184 [ label = "AccessPoint { name: \"LTU-7280WestsideRd\", id: \"4b9ee4ab-7d64-4ad3-8463-f58f280410c5\", site_name: \"7280 Westside Rd\", download_mbps: 406, upload_mbps: 47 }" ] + 185 [ label = "AccessPoint { name: \"WavePro-TuscanyToBinational\", id: \"37192d82-1f9a-4e30-acbe-f881be66fc39\", site_name: \"Tuscany Ridge\", download_mbps: 1751, upload_mbps: 1617 }" ] + 186 [ label = "AccessPoint { name: \"JR_AP_TC_B\", id: \"ca401111-a09a-439c-bda9-c6e864b53b5c\", site_name: \"TrailerCity\", download_mbps: 1000, upload_mbps: 1000 }" ] + 187 [ label = "AccessPoint { name: \"UISP-S-Plus B76\", id: \"88e0c8a4-d089-427c-8f87-08e68ee8dae8\", site_name: \"TrailerCity\", download_mbps: 5000, upload_mbps: 5000 }" ] + 188 [ label = "AccessPoint { name: \"ButterfieldSwitch\", id: \"59ef8c48-1162-48a8-90ba-d62969709ca1\", site_name: \"Butterfield\", download_mbps: 5000, upload_mbps: 5000 }" ] + 189 [ label = "AccessPoint { name: \"MedanoHeights_Apt5_EdgeSwitch18X\", id: \"e17c4b16-5470-4541-b7f7-0de2f57e4744\", site_name: \"Medano Heights Apt 5\", download_mbps: 5000, upload_mbps: 5000 }" ] + 190 [ label = "AccessPoint { name: \"MedanoHeights_Apt7_EdgeSwitch18X\", id: \"0d9543eb-3f11-4a94-b88a-ec0153b11c4e\", site_name: \"Medano Heights Apt 7\", download_mbps: 5000, upload_mbps: 5000 }" ] + 191 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt3_To_Apt2\", id: \"a6297092-ce4a-4849-bffe-a1cdc3018285\", site_name: \"Medano Heights Apt 3\", download_mbps: 1617, upload_mbps: 1617 }" ] + 192 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt2_To_Apt3\", id: \"329113bb-8fc0-4ba4-8c1f-72499945aca5\", site_name: \"Medano Heights Apt 2\", download_mbps: 1591, upload_mbps: 1617 }" ] + 193 [ label = "GeneratedSite { name: \"Orphans\" }" ] + 194 [ label = "AccessPoint { name: \"EdgeSwitch-MRE-2\", id: \"7b292e18-e535-49e3-b1b5-1555c83fb530\", site_name: \"MRE\", download_mbps: 5000, upload_mbps: 5000 }" ] + 195 [ label = "AccessPoint { name: \"JR_AP_VA_A\", id: \"dbdce68f-7b16-47a3-98c2-844f7fba1b45\", site_name: \"Valley Acres\", download_mbps: 230, upload_mbps: 75 }" ] + 196 [ label = "AccessPoint { name: \"EdgePoint-MonteDelSol\", id: \"65a70e28-92b0-4cf9-8fa6-414fba4aa418\", site_name: \"Monte Del Sol\", download_mbps: 5000, upload_mbps: 5000 }" ] + 197 [ label = "AccessPoint { name: \"MedanoHeights_Apt3_EdgeSwitch18X\", id: \"117b3439-fb0a-475c-8495-ce995aed9686\", site_name: \"Medano Heights Apt 3\", download_mbps: 5000, upload_mbps: 5000 }" ] + 198 [ label = "AccessPoint { name: \"MedanoHeights_Apt3_EdgeSwitch18X\", id: \"117b3439-fb0a-475c-8495-ce995aed9686\", site_name: \"Medano Heights Apt 3\", download_mbps: 5000, upload_mbps: 5000 }" ] + 199 [ label = "AccessPoint { name: \"EPS16-2800AirportRd\", id: \"b71e77c3-0c41-4672-a55d-75c634461fb5\", site_name: \"2800 Airport Road\", download_mbps: 5000, upload_mbps: 5000 }" ] + 200 [ label = "AccessPoint { name: \"GBE-8to7\", id: \"616d98d0-1e19-43b1-96d5-a17fb4cb4d29\", site_name: \"Medano Heights Apt 8\", download_mbps: 1413, upload_mbps: 1617 }" ] + 201 [ label = "AccessPoint { name: \"LTU-1395Koogle\", id: \"1864ff29-23a5-46a1-9ac5-9954f096beae\", site_name: \"1395 Koogle PoP\", download_mbps: 196, upload_mbps: 55 }" ] + 202 [ label = "AccessPoint { name: \"JR_AP_OO_A\", id: \"efad713b-6d77-43b9-b129-f77c5ed0fb28\", site_name: \"Oro Onions PoP\", download_mbps: 1000, upload_mbps: 1000 }" ] + 203 [ label = "AccessPoint { name: \"JR_AP_TR_F\", id: \"342f8bc0-af9c-44c4-a669-bb7ae4fc00a4\", site_name: \"Tuscany Ridge\", download_mbps: 330, upload_mbps: 63 }" ] + 204 [ label = "AccessPoint { name: \"JR_AP_MT_A\", id: \"0f81cf09-246e-4d06-8354-6a815b7cb202\", site_name: \"Monte Del Sol\", download_mbps: 360, upload_mbps: 83 }" ] + 205 [ label = "AccessPoint { name: \"GigaBeam_Medano_Apt5_To_Apt3\", id: \"e4baac95-6cc7-4679-afbd-032d70080500\", site_name: \"Medano Heights Apt 5\", download_mbps: 1574, upload_mbps: 1617 }" ] + 206 [ label = "AccessPoint { name: \"AF5XHD-2800AirportRd\", id: \"569b8f1d-53a9-464b-ab9e-ef245934b753\", site_name: \"2800 Airport Road\", download_mbps: 134, upload_mbps: 129 }" ] + 207 [ label = "AccessPoint { name: \"JR_AP_MRE_60_NW\", id: \"7032424a-754c-4410-ab47-c43d864e2b94\", site_name: \"MRE\", download_mbps: 2156, upload_mbps: 1751 }" ] + 208 [ label = "AccessPoint { name: \"JR_AP_MRE_60_SW\", id: \"c926a6f2-b92e-4491-92fd-a8004abcacb2\", site_name: \"MRE\", download_mbps: 1000, upload_mbps: 875 }" ] + 209 [ label = "AccessPoint { name: \"LBE-GatoRdPoP\", id: \"0332304e-c62d-4ae2-9400-03da8d5ce80b\", site_name: \"6360 Gato Rd PoP\", download_mbps: 196, upload_mbps: 196 }" ] + 210 [ label = "AccessPoint { name: \"MedanoHeights_Apt4_EdgeSwitch18X\", id: \"1a24ceca-0323-4abe-99ed-b989901f881e\", site_name: \"Medano Heights Apt 4\", download_mbps: 5000, upload_mbps: 5000 }" ] + 211 [ label = "AccessPoint { name: \"LTU-6360GatoRd\", id: \"572aafc1-0acd-416c-b556-3e7a0686fd5f\", site_name: \"6360 Gato Rd PoP\", download_mbps: 289, upload_mbps: 87 }" ] + 212 [ label = "AccessPoint { name: \"MedanoHeights_Apt2_EdgeSwitch18X\", id: \"a4442c18-8a6b-40c0-a6c5-f4b22a5b8274\", site_name: \"Medano Heights Apt 2\", download_mbps: 5000, upload_mbps: 5000 }" ] + 213 [ label = "AccessPoint { name: \"LTU-1308DesiertoSeco\", id: \"21913296-5bf5-43f3-b4e1-921e742ccc5f\", site_name: \"1308 Desierto Seco PoP\", download_mbps: 406, upload_mbps: 94 }" ] + 214 [ label = "AccessPoint { name: \"LiteAP-2801Girlscout\", id: \"f703c5a9-31ba-4ead-b73c-b5f6ffe0c95f\", site_name: \"2801 Girl Scout Lane\", download_mbps: 296, upload_mbps: 276 }" ] + 215 [ label = "AccessPoint { name: \"JR_AP_GS_S\", id: \"cf52a6ef-86f0-4cb4-9d5c-bd27fd321bec\", site_name: \"2801 Girl Scout Lane\", download_mbps: 130, upload_mbps: 78 }" ] + 216 [ label = "AccessPoint { name: \"LTU-Santiago\", id: \"1f8aa750-7960-4695-905c-fb580a8fa58b\", site_name: \"Santiago\", download_mbps: 244, upload_mbps: 47 }" ] + 217 [ label = "AccessPoint { name: \"JackRabbit_R\", id: \"d96da0d5-c79b-4f69-a94b-3df1cf88167a\", site_name: \"7232 Rochester\", download_mbps: 280, upload_mbps: 80 }" ] + 218 [ label = "AccessPoint { name: \"JackRabbit_J\", id: \"3d14b959-60f7-4281-80c0-b7f229cf763f\", site_name: \"WestRedd Tower\", download_mbps: 194, upload_mbps: 105 }" ] + 41 -> 12 [ ] + 12 -> 41 [ ] + 42 -> 1 [ ] + 1 -> 42 [ ] + 71 -> 214 [ ] + 214 -> 71 [ ] + 44 -> 2 [ ] + 2 -> 44 [ ] + 45 -> 8 [ ] + 8 -> 45 [ ] + 46 -> 40 [ ] + 40 -> 46 [ ] + 47 -> 39 [ ] + 39 -> 47 [ ] + 48 -> 34 [ ] + 34 -> 48 [ ] + 49 -> 37 [ ] + 37 -> 49 [ ] + 50 -> 39 [ ] + 39 -> 50 [ ] + 51 -> 6 [ ] + 6 -> 51 [ ] + 52 -> 38 [ ] + 38 -> 52 [ ] + 53 -> 14 [ ] + 14 -> 53 [ ] + 54 -> 0 [ ] + 0 -> 54 [ ] + 55 -> 38 [ ] + 38 -> 55 [ ] + 56 -> 39 [ ] + 39 -> 56 [ ] + 57 -> 5 [ ] + 5 -> 57 [ ] + 58 -> 39 [ ] + 39 -> 58 [ ] + 59 -> 19 [ ] + 19 -> 59 [ ] + 60 -> 15 [ ] + 15 -> 60 [ ] + 61 -> 28 [ ] + 28 -> 61 [ ] + 62 -> 40 [ ] + 40 -> 62 [ ] + 63 -> 4 [ ] + 4 -> 63 [ ] + 64 -> 0 [ ] + 0 -> 64 [ ] + 65 -> 8 [ ] + 8 -> 65 [ ] + 66 -> 38 [ ] + 38 -> 66 [ ] + 67 -> 23 [ ] + 23 -> 67 [ ] + 68 -> 34 [ ] + 34 -> 68 [ ] + 69 -> 9 [ ] + 9 -> 69 [ ] + 70 -> 11 [ ] + 11 -> 70 [ ] + 71 -> 5 [ ] + 5 -> 71 [ ] + 149 -> 114 [ ] + 114 -> 149 [ ] + 73 -> 14 [ ] + 14 -> 73 [ ] + 74 -> 40 [ ] + 40 -> 74 [ ] + 206 -> 135 [ ] + 139 -> 190 [ ] + 76 -> 38 [ ] + 38 -> 76 [ ] + 77 -> 34 [ ] + 34 -> 77 [ ] + 78 -> 20 [ ] + 20 -> 78 [ ] + 79 -> 23 [ ] + 23 -> 79 [ ] + 80 -> 27 [ ] + 27 -> 80 [ ] + 81 -> 38 [ ] + 38 -> 81 [ ] + 82 -> 9 [ ] + 9 -> 82 [ ] + 83 -> 34 [ ] + 34 -> 83 [ ] + 84 -> 34 [ ] + 34 -> 84 [ ] + 85 -> 20 [ ] + 20 -> 85 [ ] + 86 -> 1 [ ] + 1 -> 86 [ ] + 87 -> 40 [ ] + 40 -> 87 [ ] + 76 -> 120 [ ] + 153 -> 151 [ ] + 89 -> 4 [ ] + 4 -> 89 [ ] + 90 -> 13 [ ] + 13 -> 90 [ ] + 91 -> 40 [ ] + 40 -> 91 [ ] + 92 -> 2 [ ] + 2 -> 92 [ ] + 93 -> 34 [ ] + 34 -> 93 [ ] + 162 -> 135 [ ] + 184 -> 92 [ ] + 95 -> 34 [ ] + 34 -> 95 [ ] + 96 -> 34 [ ] + 34 -> 96 [ ] + 97 -> 18 [ ] + 18 -> 97 [ ] + 98 -> 12 [ ] + 12 -> 98 [ ] + 99 -> 26 [ ] + 26 -> 99 [ ] + 100 -> 8 [ ] + 8 -> 100 [ ] + 101 -> 31 [ ] + 31 -> 101 [ ] + 102 -> 38 [ ] + 38 -> 102 [ ] + 103 -> 25 [ ] + 25 -> 103 [ ] + 73 -> 86 [ ] + 163 -> 211 [ ] + 105 -> 24 [ ] + 24 -> 105 [ ] + 106 -> 34 [ ] + 34 -> 106 [ ] + 107 -> 15 [ ] + 15 -> 107 [ ] + 108 -> 26 [ ] + 26 -> 108 [ ] + 109 -> 40 [ ] + 40 -> 109 [ ] + 87 -> 73 [ ] + 73 -> 87 [ ] + 111 -> 38 [ ] + 38 -> 111 [ ] + 112 -> 38 [ ] + 38 -> 112 [ ] + 113 -> 6 [ ] + 6 -> 113 [ ] + 114 -> 9 [ ] + 9 -> 114 [ ] + 2 -> 37 [ ] + 9 -> 37 [ ] + 116 -> 2 [ ] + 2 -> 116 [ ] + 117 -> 38 [ ] + 38 -> 117 [ ] + 118 -> 3 [ ] + 3 -> 118 [ ] + 119 -> 2 [ ] + 2 -> 119 [ ] + 120 -> 16 [ ] + 16 -> 120 [ ] + 121 -> 40 [ ] + 40 -> 121 [ ] + 122 -> 37 [ ] + 37 -> 122 [ ] + 123 -> 7 [ ] + 7 -> 123 [ ] + 124 -> 40 [ ] + 40 -> 124 [ ] + 125 -> 39 [ ] + 39 -> 125 [ ] + 126 -> 9 [ ] + 9 -> 126 [ ] + 127 -> 39 [ ] + 39 -> 127 [ ] + 128 -> 9 [ ] + 9 -> 128 [ ] + 3 -> 2 [ ] + 8 -> 0 [ ] + 130 -> 26 [ ] + 26 -> 130 [ ] + 131 -> 34 [ ] + 34 -> 131 [ ] + 132 -> 24 [ ] + 24 -> 132 [ ] + 133 -> 34 [ ] + 34 -> 133 [ ] + 134 -> 33 [ ] + 33 -> 134 [ ] + 135 -> 9 [ ] + 9 -> 135 [ ] + 136 -> 11 [ ] + 11 -> 136 [ ] + 137 -> 40 [ ] + 40 -> 137 [ ] + 138 -> 26 [ ] + 26 -> 138 [ ] + 139 -> 21 [ ] + 21 -> 139 [ ] + 140 -> 38 [ ] + 38 -> 140 [ ] + 141 -> 15 [ ] + 15 -> 141 [ ] + 142 -> 9 [ ] + 9 -> 142 [ ] + 143 -> 9 [ ] + 9 -> 143 [ ] + 144 -> 9 [ ] + 9 -> 144 [ ] + 145 -> 9 [ ] + 9 -> 145 [ ] + 146 -> 39 [ ] + 39 -> 146 [ ] + 147 -> 38 [ ] + 38 -> 147 [ ] + 148 -> 17 [ ] + 17 -> 148 [ ] + 149 -> 13 [ ] + 13 -> 149 [ ] + 150 -> 20 [ ] + 20 -> 150 [ ] + 151 -> 23 [ ] + 23 -> 151 [ ] + 152 -> 39 [ ] + 39 -> 152 [ ] + 151 -> 153 [ ] + 120 -> 76 [ ] + 1 -> 34 [ ] + 9 -> 1 [ ] + 155 -> 34 [ ] + 34 -> 155 [ ] + 211 -> 163 [ ] + 107 -> 195 [ ] + 157 -> 26 [ ] + 26 -> 157 [ ] + 158 -> 2 [ ] + 2 -> 158 [ ] + 159 -> 27 [ ] + 27 -> 159 [ ] + 160 -> 39 [ ] + 39 -> 160 [ ] + 20 -> 31 [ ] + 38 -> 39 [ ] + 110 -> 178 [ ] + 195 -> 107 [ ] + 163 -> 9 [ ] + 9 -> 163 [ ] + 164 -> 32 [ ] + 32 -> 164 [ ] + 165 -> 4 [ ] + 4 -> 165 [ ] + 166 -> 14 [ ] + 14 -> 166 [ ] + 178 -> 110 [ ] + 191 -> 192 [ ] + 168 -> 34 [ ] + 34 -> 168 [ ] + 169 -> 24 [ ] + 24 -> 169 [ ] + 170 -> 25 [ ] + 25 -> 170 [ ] + 171 -> 2 [ ] + 2 -> 171 [ ] + 172 -> 38 [ ] + 38 -> 172 [ ] + 173 -> 2 [ ] + 2 -> 173 [ ] + 174 -> 5 [ ] + 5 -> 174 [ ] + 175 -> 29 [ ] + 29 -> 175 [ ] + 176 -> 28 [ ] + 28 -> 176 [ ] + 177 -> 13 [ ] + 13 -> 177 [ ] + 193 -> 1 [ ] + 103 -> 88 [ ] + 70 -> 45 [ ] + 45 -> 70 [ ] + 180 -> 39 [ ] + 39 -> 180 [ ] + 135 -> 162 [ ] + 92 -> 184 [ ] + 201 -> 114 [ ] + 114 -> 201 [ ] + 190 -> 139 [ ] + 135 -> 206 [ ] + 184 -> 19 [ ] + 19 -> 184 [ ] + 185 -> 9 [ ] + 9 -> 185 [ ] + 186 -> 16 [ ] + 16 -> 186 [ ] + 187 -> 16 [ ] + 16 -> 187 [ ] + 188 -> 35 [ ] + 35 -> 188 [ ] + 189 -> 22 [ ] + 22 -> 189 [ ] + 190 -> 10 [ ] + 10 -> 190 [ ] + 164 -> 123 [ ] + 123 -> 164 [ ] + 192 -> 30 [ ] + 30 -> 192 [ ] + 213 -> 203 [ ] + 203 -> 213 [ ] + 194 -> 38 [ ] + 38 -> 194 [ ] + 182 -> 72 [ ] + 88 -> 103 [ ] + 196 -> 37 [ ] + 37 -> 196 [ ] + 197 -> 29 [ ] + 29 -> 197 [ ] + 198 -> 29 [ ] + 29 -> 198 [ ] + 199 -> 36 [ ] + 36 -> 199 [ ] + 200 -> 21 [ ] + 21 -> 200 [ ] + 201 -> 28 [ ] + 28 -> 201 [ ] + 1 -> 193 [ ] + 72 -> 182 [ ] + 203 -> 9 [ ] + 9 -> 203 [ ] + 193 -> 35 [ ] + 35 -> 193 [ ] + 29 -> 39 [ ] + 29 -> 10 [ ] + 206 -> 36 [ ] + 36 -> 206 [ ] + 207 -> 38 [ ] + 38 -> 207 [ ] + 208 -> 38 [ ] + 38 -> 208 [ ] + 7 -> 29 [ ] + 29 -> 7 [ ] + 39 -> 31 [ ] + 39 -> 1 [ ] + 211 -> 18 [ ] + 18 -> 211 [ ] + 31 -> 20 [ ] + 39 -> 38 [ ] + 213 -> 6 [ ] + 6 -> 213 [ ] + 214 -> 12 [ ] + 12 -> 214 [ ] + 215 -> 12 [ ] + 12 -> 215 [ ] + 216 -> 27 [ ] + 27 -> 216 [ ] + 217 -> 39 [ ] + 39 -> 217 [ ] + 26 -> 2 [ ] + 31 -> 9 [ ] + 43 -> 39 [ ] + 39 -> 43 [ ] + 72 -> 24 [ ] + 24 -> 72 [ ] + 75 -> 38 [ ] + 38 -> 75 [ ] + 88 -> 9 [ ] + 9 -> 88 [ ] + 94 -> 33 [ ] + 33 -> 94 [ ] + 104 -> 33 [ ] + 33 -> 104 [ ] + 110 -> 10 [ ] + 10 -> 110 [ ] + 115 -> 39 [ ] + 39 -> 115 [ ] + 129 -> 16 [ ] + 16 -> 129 [ ] + 153 -> 26 [ ] + 26 -> 153 [ ] + 154 -> 3 [ ] + 3 -> 154 [ ] + 156 -> 38 [ ] + 38 -> 156 [ ] + 161 -> 2 [ ] + 2 -> 161 [ ] + 162 -> 4 [ ] + 4 -> 162 [ ] + 37 -> 9 [ ] + 37 -> 2 [ ] + 178 -> 17 [ ] + 17 -> 178 [ ] + 179 -> 16 [ ] + 16 -> 179 [ ] + 181 -> 38 [ ] + 38 -> 181 [ ] + 182 -> 9 [ ] + 9 -> 182 [ ] + 1 -> 9 [ ] + 34 -> 1 [ ] + 191 -> 29 [ ] + 29 -> 191 [ ] + 2 -> 0 [ ] + 16 -> 38 [ ] + 195 -> 25 [ ] + 25 -> 195 [ ] + 202 -> 20 [ ] + 20 -> 202 [ ] + 204 -> 37 [ ] + 37 -> 204 [ ] + 205 -> 22 [ ] + 22 -> 205 [ ] + 209 -> 18 [ ] + 18 -> 209 [ ] + 210 -> 7 [ ] + 7 -> 210 [ ] + 212 -> 30 [ ] + 30 -> 212 [ ] + 218 -> 34 [ ] + 34 -> 218 [ ] + 167 -> 3 [ ] + 3 -> 167 [ ] + 183 -> 32 [ ] + 32 -> 183 [ ] + 0 -> 8 [ ] + 2 -> 3 [ ] + 0 -> 2 [ ] + 38 -> 16 [ ] + 175 -> 205 [ ] + 205 -> 175 [ ] + 92 -> 216 [ ] + 216 -> 92 [ ] + 217 -> 98 [ ] + 98 -> 217 [ ] + 1 -> 39 [ ] + 31 -> 39 [ ] + 192 -> 191 [ ] + 86 -> 73 [ ] + 2 -> 26 [ ] + 9 -> 31 [ ] + 217 -> 104 [ ] + 104 -> 217 [ ] + 10 -> 29 [ ] + 39 -> 29 [ ] +} diff --git a/src/rust/graph.png b/src/rust/graph.png new file mode 100644 index 00000000..0ca4e70e Binary files /dev/null and b/src/rust/graph.png differ diff --git a/src/rust/lqos_bakery/src/diff/mod.rs b/src/rust/lqos_bakery/src/diff/mod.rs index 020a029d..0af8f324 100644 --- a/src/rust/lqos_bakery/src/diff/mod.rs +++ b/src/rust/lqos_bakery/src/diff/mod.rs @@ -2,4 +2,4 @@ mod circuits; mod sites; pub(crate) use circuits::{CircuitDiffResult, diff_circuits}; -pub(crate) use sites::{SiteDiffResult, diff_sites}; +pub(crate) use sites::{SiteDiffResult, StructuralSiteDiffDetails, diff_sites}; diff --git a/src/rust/lqos_bakery/src/diff/sites.rs b/src/rust/lqos_bakery/src/diff/sites.rs index 3c4cd142..d79a6f08 100644 --- a/src/rust/lqos_bakery/src/diff/sites.rs +++ b/src/rust/lqos_bakery/src/diff/sites.rs @@ -5,9 +5,21 @@ use tracing::{debug, warn}; const SITE_DELTA_PREVIEW_THRESHOLD: usize = 8; +/// Structured metadata about a site-level structural diff that forced a full rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StructuralSiteDiffDetails { + /// Stable Bakery site hash for the structurally changed site. + pub(crate) site_hash: i64, +} + pub(crate) enum SiteDiffResult { - RebuildRequired { summary: String }, - SpeedChanges { changes: Vec }, + RebuildRequired { + summary: String, + details: Option, + }, + SpeedChanges { + changes: Vec, + }, NoChange, } @@ -50,6 +62,7 @@ pub(crate) fn diff_sites( &added_site_hashes, &removed_site_hashes, ), + details: None, }; } @@ -102,6 +115,9 @@ pub(crate) fn diff_sites( "Bakery full reload triggered by site diff: site_hash={} parent={}→{} up_parent={}→{} minor=0x{:x}→0x{:x}", site_hash, opar, npar, oup, nup, omin, nmin ), + details: Some(StructuralSiteDiffDetails { + site_hash: *site_hash, + }), }; } // If the speeds have changed, we need to store the change. @@ -124,6 +140,7 @@ pub(crate) fn diff_sites( &added_site_hashes, &removed_site_hashes, ), + details: None, }; } } @@ -306,7 +323,7 @@ fn site_speeds_changed(a: &BakeryCommands, b: &BakeryCommands) -> Option Option { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + class_minor, + .. + } = command + else { + return None; + }; + + Some(format!( + "parent={} up_parent={} minor=0x{:x}", + parent_class_id.as_tc_string(), + up_parent_class_id.as_tc_string(), + class_minor + )) +} + +fn find_site_command_in_batch( + batch: &[Arc], + site_hash: i64, +) -> Option<&Arc> { + batch.iter().find(|command| { + matches!( + command.as_ref(), + BakeryCommands::AddSite { + site_hash: command_site_hash, + .. + } if *command_site_hash == site_hash + ) + }) +} + +fn format_virtualized_site_source( + owner_site_hash: i64, + owner_state: &VirtualizedSiteState, + origin: &str, +) -> String { + format!( + "{origin}(owner_site_hash={owner_site_hash}, owner_site_name={}, lifecycle={:?}, active_branch={:?}, pending_prune={})", + owner_state.site_name, + owner_state.lifecycle, + owner_state.active_branch, + owner_state.pending_prune + ) +} + +fn log_structural_site_diff_baseline_origin( + details: StructuralSiteDiffDetails, + sites: &HashMap>, + baseline_sites: &HashMap>, + raw_batch: &[Arc], + virtualized_sites: &HashMap, +) { + let Some(baseline_command) = baseline_sites.get(&details.site_hash) else { + warn!( + "Bakery structural site diff baseline origin: site_hash={} baseline_command_missing", + details.site_hash + ); + return; + }; + + let baseline_summary = site_command_structure_summary(baseline_command.as_ref()) + .unwrap_or_else(|| "non-site-command".to_string()); + let current_summary = sites + .get(&details.site_hash) + .and_then(|command| site_command_structure_summary(command.as_ref())) + .unwrap_or_else(|| "missing".to_string()); + let new_summary = find_site_command_in_batch(raw_batch, details.site_hash) + .and_then(|command| site_command_structure_summary(command.as_ref())) + .unwrap_or_else(|| "missing".to_string()); + + let mut baseline_sources = Vec::new(); + if let Some(current_command) = sites.get(&details.site_hash) + && Arc::ptr_eq(current_command, baseline_command) + { + baseline_sources.push("current_sites".to_string()); + } + + for (owner_site_hash, owner_state) in virtualized_sites { + if Arc::ptr_eq(&owner_state.site, baseline_command) { + baseline_sources.push(format_virtualized_site_source( + *owner_site_hash, + owner_state, + "virtualized_state.site", + )); + } + if let Some(saved_site) = owner_state.saved_sites.get(&details.site_hash) + && Arc::ptr_eq(saved_site, baseline_command) + { + baseline_sources.push(format_virtualized_site_source( + *owner_site_hash, + owner_state, + "virtualized_state.saved_sites", + )); + } + } + + if baseline_sources.is_empty() { + baseline_sources.push("unknown".to_string()); + } + + warn!( + "Bakery structural site diff baseline origin: site_hash={} baseline_sources=[{}] baseline={} current={} new={}", + details.site_hash, + baseline_sources.join(", "), + baseline_summary, + current_summary, + new_summary + ); +} + fn runtime_virtualized_site_has_pending_migrations( state: &VirtualizedSiteState, migrations: &HashMap, diff --git a/src/rust/lqos_config/src/etc/v15/example.toml b/src/rust/lqos_config/src/etc/v15/example.toml index c4b6b37c..43b37393 100644 --- a/src/rust/lqos_config/src/etc/v15/example.toml +++ b/src/rust/lqos_config/src/etc/v15/example.toml @@ -155,6 +155,8 @@ snmp_community = "public" airmax_model_ids = [ "" ] ltu_model_ids = [ "" ] active_status_ids = [ "" ] +recurring_excluded_service_names = [] +recurring_service_rates = [] [influxdb] enable_influxdb = false diff --git a/src/rust/lqos_config/src/etc/v15/sonar_integration.rs b/src/rust/lqos_config/src/etc/v15/sonar_integration.rs index 9dafe114..2651f564 100644 --- a/src/rust/lqos_config/src/etc/v15/sonar_integration.rs +++ b/src/rust/lqos_config/src/etc/v15/sonar_integration.rs @@ -1,6 +1,14 @@ use allocative::Allocative; use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative, Default)] +pub struct SonarRecurringServiceRate { + pub enabled: bool, + pub service_name: String, + pub download_mbps: f32, + pub upload_mbps: f32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] pub struct SonarIntegration { pub enable_sonar: bool, @@ -10,6 +18,10 @@ pub struct SonarIntegration { pub airmax_model_ids: Vec, pub ltu_model_ids: Vec, pub active_status_ids: Vec, + #[serde(default)] + pub recurring_service_rates: Vec, + #[serde(default)] + pub recurring_excluded_service_names: Vec, } impl Default for SonarIntegration { @@ -22,6 +34,8 @@ impl Default for SonarIntegration { airmax_model_ids: vec![], ltu_model_ids: vec![], active_status_ids: vec![], + recurring_service_rates: vec![], + recurring_excluded_service_names: vec![], } } } diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index a2fb1dfe..6d9eeba0 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -932,6 +932,8 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(sonar_airmax_ap_model_ids, m)?)?; m.add_function(wrap_pyfunction!(sonar_ltu_ap_model_ids, m)?)?; m.add_function(wrap_pyfunction!(sonar_active_status_ids, m)?)?; + m.add_function(wrap_pyfunction!(sonar_recurring_service_rates, m)?)?; + m.add_function(wrap_pyfunction!(sonar_recurring_excluded_service_names, m)?)?; m.add_function(wrap_pyfunction!(influx_db_enabled, m)?)?; m.add_function(wrap_pyfunction!(influx_db_bucket, m)?)?; m.add_function(wrap_pyfunction!(influx_db_org, m)?)?; @@ -2385,6 +2387,34 @@ fn sonar_active_status_ids() -> PyResult> { Ok(key) } +#[pyfunction] +fn sonar_recurring_service_rates() -> PyResult> { + let config = lqos_config::load_config().unwrap(); + let rules = config + .sonar_integration + .recurring_service_rates + .iter() + .map(|rule| { + ( + rule.enabled, + rule.service_name.clone(), + rule.download_mbps, + rule.upload_mbps, + ) + }) + .collect(); + Ok(rules) +} + +#[pyfunction] +fn sonar_recurring_excluded_service_names() -> PyResult> { + let config = lqos_config::load_config().unwrap(); + Ok(config + .sonar_integration + .recurring_excluded_service_names + .clone()) +} + #[pyfunction] fn influx_db_enabled() -> PyResult { let config = lqos_config::load_config().unwrap(); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js index 0047a3ae..d154837e 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js @@ -489,6 +489,20 @@ function initializeDeviceGraphs() { } } +function ensureDeviceGraphs() { + const target = document.getElementById("devs"); + if (!target || !isElementVisible(target)) { + return; + } + runWhenRenderable(target, () => { + if (!hasRenderableSize(target)) { + return; + } + initializeDeviceGraphs(); + resizeDeviceGraphs(); + }); +} + function resizeDeviceGraphs() { Object.values(deviceGraphs).forEach((graph) => resizeGraphIfVisible(graph)); } @@ -912,8 +926,7 @@ function initTabLifecycle(parentNode) { return; } if (target === "#devs") { - initializeDeviceGraphs(); - resizeDeviceGraphs(); + ensureDeviceGraphs(); syncCircuitDetailSubscriptions(); return; } @@ -1546,6 +1559,15 @@ function sortTopAsnRows(rows) { rows.sort((a, b) => { const asc = topAsnSortDirection === "asc"; const normalize = (value) => typeof value === "string" ? value.toLowerCase() : value; + const totalQoo = (row) => { + const scores = [row?.qoo_down, row?.qoo_up] + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + if (!scores.length) { + return asc ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } + return scores.reduce((sum, value) => sum + value, 0); + }; let aVal; let bVal; switch (topAsnSortColumn) { @@ -1557,13 +1579,13 @@ function sortTopAsnRows(rows) { aVal = normalize(a.asn_country); bVal = normalize(b.asn_country); break; - case "bytes": - aVal = a.bytes_sent_down + a.bytes_sent_up; - bVal = b.bytes_sent_down + b.bytes_sent_up; + case "rtt": + aVal = toNumber(a.rtt_down_nanos, 0) + toNumber(a.rtt_up_nanos, 0); + bVal = toNumber(b.rtt_down_nanos, 0) + toNumber(b.rtt_up_nanos, 0); break; - case "packets": - aVal = a.packets_sent_down + a.packets_sent_up; - bVal = b.packets_sent_down + b.packets_sent_up; + case "qoo": + aVal = totalQoo(a); + bVal = totalQoo(b); break; case "retransmits": aVal = a.retransmit_down_pct + a.retransmit_up_pct; @@ -1611,7 +1633,7 @@ function renderTopAsnTab() { topAsnSortDirection = topAsnSortDirection === "asc" ? "desc" : "asc"; } else { topAsnSortColumn = sortKey; - topAsnSortDirection = "desc"; + topAsnSortDirection = sortKey === "qoo" ? "asc" : "desc"; } renderTopAsnTab(); }; @@ -1624,8 +1646,8 @@ function renderTopAsnTab() { thead.appendChild(createSortableHeader("ASN", "asn")); thead.appendChild(createSortableHeader("Country", "country")); thead.appendChild(createSortableHeader("Current Rate (d/u)", "rate", 2)); - thead.appendChild(createSortableHeader("Total Bytes (d/u)", "bytes", 2)); - thead.appendChild(createSortableHeader("Total Packets (d/u)", "packets", 2)); + thead.appendChild(createSortableHeader("RTT (d/u)", "rtt", 2)); + thead.appendChild(createSortableHeader("QoO (d/u)", "qoo", 2)); thead.appendChild(createSortableHeader("TCP rxmit (d/u)", "retransmits", 2)); thead.appendChild(createSortableHeader("Flows", "flows")); table.appendChild(thead); @@ -1648,10 +1670,10 @@ function renderTopAsnTab() { row.appendChild(truncatedTrafficCell(rowData.asn_country, "lqos-circuit-traffic-country-cell")); row.appendChild(simpleRowHtml(formatThroughput(rowData.down_bps, plan.down))); row.appendChild(simpleRowHtml(formatThroughput(rowData.up_bps, plan.up))); - row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_down))); - row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_up))); - row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_down))); - row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_up))); + row.appendChild(simpleRowHtml(formatRttNanos(rowData.rtt_down_nanos))); + row.appendChild(simpleRowHtml(formatRttNanos(rowData.rtt_up_nanos))); + row.appendChild(simpleRowHtml(formatQooScore(rowData.qoo_down))); + row.appendChild(simpleRowHtml(formatQooScore(rowData.qoo_up))); row.appendChild(simpleRowHtml(rowData.retransmit_down_pct > 0 ? formatRetransmitFraction(rowData.retransmit_down_pct) : "-")); row.appendChild(simpleRowHtml(rowData.retransmit_up_pct > 0 ? formatRetransmitFraction(rowData.retransmit_up_pct) : "-")); row.appendChild(simpleRow(scaleNumber(rowData.flow_count))); @@ -1790,15 +1812,15 @@ function fillLiveDevices(devices) { if (throughputDown !== null) { throughputDown.innerHTML = formatThroughput( - toNumber(device.bytes_per_second.down, 0) * 8, - toNumber(device.plan.down, 0) + toNumber(device.bytes_per_second?.down, 0) * 8, + toNumber(device.plan?.down, 0) ); } if (throughputUp !== null) { throughputUp.innerHTML = formatThroughput( - toNumber(device.bytes_per_second.up, 0) * 8, - toNumber(device.plan.up, 0) + toNumber(device.bytes_per_second?.up, 0) * 8, + toNumber(device.plan?.up, 0) ); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 6b1bdf86..7a149f99 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -34,6 +34,25 @@ function ensureOptionalConfigSections(config) { config.splynx_integration = {}; } + if (!config.sonar_integration || typeof config.sonar_integration !== "object") { + config.sonar_integration = {}; + } + + const sonar = config.sonar_integration; + if (typeof sonar.enable_sonar !== "boolean") sonar.enable_sonar = false; + if (typeof sonar.sonar_api_url !== "string") sonar.sonar_api_url = ""; + if (typeof sonar.sonar_api_key !== "string") sonar.sonar_api_key = ""; + if (typeof sonar.snmp_community !== "string") sonar.snmp_community = "public"; + if (!Array.isArray(sonar.airmax_model_ids)) sonar.airmax_model_ids = []; + if (!Array.isArray(sonar.ltu_model_ids)) sonar.ltu_model_ids = []; + if (!Array.isArray(sonar.active_status_ids)) sonar.active_status_ids = []; + if (!Array.isArray(sonar.recurring_excluded_service_names)) { + sonar.recurring_excluded_service_names = []; + } + if (!Array.isArray(sonar.recurring_service_rates)) { + sonar.recurring_service_rates = []; + } + const splynx = config.splynx_integration; if (typeof splynx.enable_splynx !== "boolean") splynx.enable_splynx = false; if (typeof splynx.api_key !== "string") splynx.api_key = ""; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_iprange.js b/src/rust/lqosd/src/node_manager/js_build/src/config_iprange.js index 439eb40e..5aed0dd2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_iprange.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_iprange.js @@ -1,30 +1,43 @@ import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper"; +function isValidIPv4(ip) { + if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false; + const parts = ip.split('.').map((p) => parseInt(p, 10)); + return parts.length === 4 && !parts.some((p) => Number.isNaN(p) || p < 0 || p > 255); +} + +function isValidIPv6(ip) { + return ip.includes(':') && /^[0-9a-fA-F:]+$/.test(ip); +} + +function normalizeSubnetInput(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + if (raw.includes('/')) return raw; + if (isValidIPv4(raw)) return `${raw}/32`; + if (isValidIPv6(raw)) return `${raw}/128`; + return raw; +} + function isValidCIDR(cidr) { try { - const [ip, mask] = cidr.split('/'); - if (!ip || !mask) return false; - - // Validate IP address + const [ip, mask, extra] = String(cidr).trim().split('/'); + if (!ip || !mask || extra !== undefined) return false; + if (ip.includes(':')) { - // IPv6 - if (!/^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/.test(ip)) return false; - } else { - // IPv4 - if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false; + if (!isValidIPv6(ip)) return false; + } else if (!isValidIPv4(ip)) { + return false; } - - // Validate mask - const maskNum = parseInt(mask); - if (isNaN(maskNum)) return false; + + const maskNum = parseInt(mask, 10); + if (Number.isNaN(maskNum)) return false; if (ip.includes(':')) { - // IPv6 if (maskNum < 0 || maskNum > 128) return false; } else { - // IPv4 if (maskNum < 0 || maskNum > 32) return false; } - + return true; } catch { return false; @@ -44,17 +57,16 @@ function populateSubnetList(selectId, subnets) { function addSubnet(listId, inputId) { const input = document.getElementById(inputId); - const cidr = input.value.trim(); - + const cidr = normalizeSubnetInput(input.value); + if (!isValidCIDR(cidr)) { - alert('Please enter a valid CIDR notation (e.g. 192.168.1.0/24 or 2001:db8::/32)'); + alert('Please enter a valid IP or CIDR notation (e.g. 192.168.1.0/24, 2803:16d0:40::/48, or 2001:db8::1)'); return; } - + const select = document.getElementById(listId); - // Check for duplicates for (let i = 0; i < select.options.length; i++) { - if (select.options[i].value === cidr) { + if (select.options[i].value.toLowerCase() === cidr.toLowerCase()) { alert('This CIDR is already in the list'); return; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_sonar.js b/src/rust/lqosd/src/node_manager/js_build/src/config_sonar.js index 465372c4..c87c4d28 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_sonar.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_sonar.js @@ -8,6 +8,50 @@ function stringToArray(str) { return str ? str.split(',').map(s => s.trim()).filter(s => s.length > 0) : []; } +function sanitizeRecurringRate(rule = {}) { + return { + enabled: rule.enabled !== false, + service_name: (rule.service_name ?? "").trim(), + download_mbps: Number(rule.download_mbps ?? 0), + upload_mbps: Number(rule.upload_mbps ?? 0), + }; +} + +function buildRecurringRateRow(rule = {}) { + const row = document.createElement("tr"); + const sanitized = sanitizeRecurringRate(rule); + row.innerHTML = ` + + + + + + `; + row.querySelector(".recurring-enabled").checked = sanitized.enabled; + row.querySelector(".recurring-service-name").value = sanitized.service_name; + row.querySelector(".recurring-download").value = Number.isFinite(sanitized.download_mbps) ? sanitized.download_mbps : 0; + row.querySelector(".recurring-upload").value = Number.isFinite(sanitized.upload_mbps) ? sanitized.upload_mbps : 0; + row.querySelector(".recurring-remove").addEventListener("click", () => row.remove()); + return row; +} + +function renderRecurringRateRows(rules = []) { + const tbody = document.getElementById("recurringServiceRatesBody"); + tbody.innerHTML = ""; + rules.forEach((rule) => tbody.appendChild(buildRecurringRateRow(rule))); +} + +function collectRecurringRateRows() { + return Array.from(document.querySelectorAll("#recurringServiceRatesBody tr")) + .map((row) => ({ + enabled: row.querySelector(".recurring-enabled").checked, + service_name: row.querySelector(".recurring-service-name").value.trim(), + download_mbps: Number(row.querySelector(".recurring-download").value), + upload_mbps: Number(row.querySelector(".recurring-upload").value), + })) + .filter((rule) => rule.service_name.length > 0); +} + function validateConfig() { // Validate required fields when enabled if (document.getElementById("enableSonar").checked) { @@ -35,6 +79,17 @@ function validateConfig() { return false; } } + + for (const rule of collectRecurringRateRows()) { + if (!Number.isFinite(rule.download_mbps) || rule.download_mbps <= 0) { + alert(`Recurring service "${rule.service_name}" must have a valid download rate`); + return false; + } + if (!Number.isFinite(rule.upload_mbps) || rule.upload_mbps <= 0) { + alert(`Recurring service "${rule.service_name}" must have a valid upload rate`); + return false; + } + } return true; } @@ -47,7 +102,9 @@ function updateConfig() { snmp_community: document.getElementById("snmpCommunity").value.trim(), airmax_model_ids: stringToArray(document.getElementById("airmaxModelIds").value), ltu_model_ids: stringToArray(document.getElementById("ltuModelIds").value), - active_status_ids: stringToArray(document.getElementById("activeStatusIds").value) + active_status_ids: stringToArray(document.getElementById("activeStatusIds").value), + recurring_excluded_service_names: stringToArray(document.getElementById("recurringExcludedServiceNames").value), + recurring_service_rates: collectRecurringRateRows(), }; } @@ -79,6 +136,12 @@ loadConfig(() => { arrayToString(sonar.ltu_model_ids); document.getElementById("activeStatusIds").value = arrayToString(sonar.active_status_ids); + document.getElementById("recurringExcludedServiceNames").value = + arrayToString(sonar.recurring_excluded_service_names); + renderRecurringRateRows(sonar.recurring_service_rates ?? []); + document.getElementById("addRecurringRateRow").addEventListener("click", () => { + document.getElementById("recurringServiceRatesBody").appendChild(buildRecurringRateRow()); + }); // Add save button click handler document.getElementById('saveButton').addEventListener('click', () => { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js index 010f38eb..90557136 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js @@ -1,4 +1,5 @@ import { clearDiv, enableTooltips, simpleRow, theading } from "./helpers/builders"; +import { redactCell } from "./helpers/redact"; import { scaleNumber, toNumber } from "./lq_js_common/helpers/scaling"; import { get_ws_client } from "./pubsub/ws"; @@ -478,6 +479,7 @@ function renderNodesTable(core) { const nameCell = document.createElement("td"); nameCell.textContent = node.name || ""; + redactCell(nameCell); tr.appendChild(nameCell); const assignmentCell = document.createElement("td"); @@ -537,9 +539,11 @@ function renderCircuits(page) { const a = document.createElement("a"); a.href = `circuit.html?id=${encodeURIComponent(c.circuit_id)}`; a.textContent = c.circuit_name; + redactCell(a); nameCell.appendChild(a); } else { nameCell.textContent = c.circuit_name || ""; + redactCell(nameCell); } tr.appendChild(nameCell); tr.appendChild(simpleRow(c.parent_node || "", true)); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js index ba26fb87..e65b1ed2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js @@ -1,4 +1,5 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {redactCell} from "../helpers/redact"; import {formatUnixSecondsToLocalDateTime, mkBadge} from "./bakery_shared"; import {renderOperationCards} from "./operation_cards"; @@ -579,6 +580,7 @@ export class BakeryActivityDashlet extends BaseDashlet { const fullSummary = displaySummary(entry); tdSummary.textContent = truncateSummary(fullSummary); tdSummary.title = fullSummary; + redactCell(tdSummary); tr.appendChild(tdTime); tr.appendChild(tdStage); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js index a3393ad4..f92c3f3c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js @@ -1,4 +1,5 @@ import {DashletBaseInsight} from "./insight_dashlet_base"; +import {redactCell} from "../helpers/redact"; import {formatUnixSecondsToLocalDateTime, mkBadge} from "./bakery_shared"; const CIRCUIT_ACTIVITY_MAX_ITEMS = 1; @@ -184,6 +185,7 @@ export class BakeryStatusDashlet extends DashletBaseInsight { const fullSummary = (entry?.summary ?? "Bakery update").toString(); summary.textContent = truncateSummary(fullSummary); summary.title = fullSummary; + redactCell(summary); top.appendChild(summary); top.appendChild(mkBadge(outcome.label, outcome.className, fullSummary)); card.appendChild(top); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js index 49e6ba1a..b8a4cdd0 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js @@ -11,6 +11,7 @@ import { retransmitHeatRow, utilizationHeatRow, MAX_HEATMAP_ROWS, + replaceHeatmapHtml, } from "./executive_heatmap_shared"; const QOO_TOOLTIP_HTML = [ @@ -55,15 +56,17 @@ function qoqHeatmapRow(blocks, colorFn) { (dlTotal === null || dlTotal === undefined) && (ulTotal === null || ulTotal === undefined); if (allMissing) { - cells += `
`; + cells += `
`; continue; } - const title = [ - `Block ${i + 1}`, - `UL Total: ${fmt(ulTotal)}`, - `DL Total: ${fmt(dlTotal)}`, - ].join(" • "); + const title = ` +
15-minute block ${i + 1}
+
Upload score: ${fmt(ulTotal)}
+
Download score: ${fmt(dlTotal)}
+
Top is upload, bottom is download. Higher scores are better.
+ `; + const aria = `15-minute block ${i + 1}. Upload score ${fmt(ulTotal)}. Download score ${fmt(dlTotal)}. Top is upload, bottom is download. Higher scores are better.`; const part = (v) => { if (v === null || v === undefined) { @@ -79,7 +82,7 @@ function qoqHeatmapRow(blocks, colorFn) { // Top = upload, bottom = download. cells += ` -
+
${part(ulTotal)} ${part(dlTotal)} @@ -265,7 +268,12 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase { const global = this.lastData?.global; const globalQoq = this.lastData?.global_qoq; if (!global) { - target.innerHTML = this.emptyCard(); + replaceHeatmapHtml(target, this.emptyCard(), (html) => { + disposeTooltipsWithin(target); + target.innerHTML = html; + target.__lqosLastHeatmapHtml = html; + enableTooltipsWithin(target); + }); return; } const rows = [ @@ -309,8 +317,7 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase { return heatRow(row.label, row.badge, row.values, row.color, row.format); }) .join(""); - disposeTooltipsWithin(target); - target.innerHTML = ` + const html = `
@@ -321,7 +328,12 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase {
`; - enableTooltipsWithin(target); + replaceHeatmapHtml(target, html, (nextHtml) => { + disposeTooltipsWithin(target); + target.innerHTML = nextHtml; + target.__lqosLastHeatmapHtml = nextHtml; + enableTooltipsWithin(target); + }); } } @@ -346,7 +358,12 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase { const summary = this.lastData || {}; const rows = this.summaryRows(summary); if (!rows.length) { - target.innerHTML = this.emptyCard(); + replaceHeatmapHtml(target, this.emptyCard(), (html) => { + disposeTooltipsWithin(target); + target.innerHTML = html; + target.__lqosLastHeatmapHtml = html; + enableTooltipsWithin(target); + }); return; } const activeTarget = document.getElementById(this._contentId); @@ -411,8 +428,7 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase { const titleHtml = this.config.link ? `${titleLabel}${linkIcon}` : `${titleLabel}`; - disposeTooltipsWithin(activeTarget); - activeTarget.innerHTML = ` + const html = `
@@ -422,7 +438,12 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase {
`; - enableTooltipsWithin(activeTarget); + replaceHeatmapHtml(activeTarget, html, (nextHtml) => { + disposeTooltipsWithin(activeTarget); + activeTarget.innerHTML = nextHtml; + activeTarget.__lqosLastHeatmapHtml = nextHtml; + enableTooltipsWithin(activeTarget); + }); } summaryRows(summary) { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_shared.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_shared.js index cc41f0ba..1fc51b37 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_shared.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_shared.js @@ -130,6 +130,59 @@ function escapeHeatAttr(text) { .replace(/>/g, ">"); } +function tooltipHtmlFromLines(lines) { + return (Array.isArray(lines) ? lines : []) + .filter((line) => !!String(line ?? "").trim()) + .map((line, idx) => `${escapeHeatAttr(line)}
`) + .join(""); +} + +function tooltipAttrsFromLines(lines) { + const filtered = (Array.isArray(lines) ? lines : []) + .map((line) => String(line ?? "").trim()) + .filter(Boolean); + const html = tooltipHtmlFromLines(filtered); + const aria = filtered.join(". "); + return `data-bs-toggle="tooltip" data-bs-placement="top" data-bs-container="body" data-bs-trigger="hover focus" data-bs-html="true" tabindex="0" title="${escapeHeatAttr(html)}" aria-label="${escapeHeatAttr(aria)}"`; +} + +function noDataTooltipLines(blockIndex, meaning) { + return [ + `15-minute block ${blockIndex}`, + "No data", + meaning || "", + ]; +} + +function scalarTooltipLines(blockIndex, valueLabel, valueText, meaning, extra = "") { + return [ + `15-minute block ${blockIndex}`, + `${valueLabel}: ${valueText}`, + meaning, + extra, + ]; +} + +function splitTooltipLines(blockIndex, topLabel, topValue, bottomLabel, bottomValue, meaning) { + return [ + `15-minute block ${blockIndex}`, + `${topLabel}: ${topValue}`, + `${bottomLabel}: ${bottomValue}`, + meaning, + ]; +} + +function rttTooltipLines(blockIndex, ulP50, ulP90, dlP50, dlP90, meaning) { + return [ + `15-minute block ${blockIndex}`, + `Upload p50 RTT: ${ulP50}`, + `Upload p90 RTT: ${ulP90}`, + `Download p50 RTT: ${dlP50}`, + `Download p90 RTT: ${dlP90}`, + meaning, + ]; +} + function latestDetailMarkup(latest, formatValue, describeValue) { const detail = describeValue ? describeValue(latest) : ""; return ` @@ -144,15 +197,21 @@ export function heatmapRow(values, colorFn, formatValue, describeValue = null) { for (let i = 0; i < length; i++) { const val = values && values[i] !== undefined ? values[i] : null; if (val === null || val === undefined) { - cells += `
`; + cells += `
`; continue; } const numeric = Number(val) || 0; const color = colorFn(numeric); const title = formatValue(numeric); const detail = describeValue ? describeValue(numeric) : ""; - const label = detail ? `Block ${i + 1}: ${title}, ${detail}` : `Block ${i + 1}: ${title}`; - cells += `
`; + const tooltipLines = scalarTooltipLines( + i + 1, + "Value", + title, + "This cell shows the metric value for this 15-minute block.", + detail ? `Context: ${detail}` : "", + ); + cells += `
`; } return cells; } @@ -176,6 +235,71 @@ export function heatRow(label, badge, values, colorFn, formatValue, link = null, `; } +function heatmapTooltipIsActive(target) { + if (!target) return false; + if (target.querySelector('[data-bs-toggle="tooltip"][aria-describedby]')) { + return true; + } + if (document.activeElement && target.contains(document.activeElement)) { + return true; + } + try { + return !!target.querySelector('[data-bs-toggle="tooltip"]:hover'); + } catch (_err) { + return false; + } +} + +function flushDeferredHeatmapHtml(target) { + if (!target) return false; + const pendingHtml = target.__lqosPendingHeatmapHtml; + if (pendingHtml === undefined || pendingHtml === null) { + return false; + } + if (heatmapTooltipIsActive(target)) { + return false; + } + delete target.__lqosPendingHeatmapHtml; + if (target.__lqosLastHeatmapHtml === pendingHtml) { + return false; + } + target.__lqosApplyHeatmapHtml?.(pendingHtml); + return true; +} + +function ensureDeferredHeatmapFlushListeners(target) { + if (!target || target.__lqosHeatmapFlushListenersAttached) { + return; + } + const scheduleFlush = () => { + window.setTimeout(() => { + flushDeferredHeatmapHtml(target); + }, 0); + }; + target.addEventListener("mouseleave", scheduleFlush); + target.addEventListener("focusout", scheduleFlush); + target.__lqosHeatmapFlushListenersAttached = true; +} + +export function replaceHeatmapHtml(target, nextHtml, applyHtml) { + if (!target || typeof applyHtml !== "function") { + return false; + } + target.__lqosApplyHeatmapHtml = applyHtml; + ensureDeferredHeatmapFlushListeners(target); + if (target.__lqosLastHeatmapHtml === nextHtml) { + delete target.__lqosPendingHeatmapHtml; + return false; + } + if (heatmapTooltipIsActive(target)) { + target.__lqosPendingHeatmapHtml = nextHtml; + return false; + } + delete target.__lqosPendingHeatmapHtml; + applyHtml(nextHtml); + return true; +} + function heatmapRowQuadrants(quads, colorFn, formatValue, describeValue = null) { const length = Array.isArray(quads?.ul_p50) && quads.ul_p50.length ? quads.ul_p50.length : 15; let cells = ""; @@ -191,19 +315,20 @@ function heatmapRowQuadrants(quads, colorFn, formatValue, describeValue = null) (dlP50 === null || dlP50 === undefined) && (dlP90 === null || dlP90 === undefined); if (allMissing) { - cells += `
`; + cells += `
`; continue; } const fmt = (v) => formatValue(v); const describe = (v) => describeValue ? ` (${describeValue(v)})` : ""; - const title = [ - `Block ${i + 1}`, - `UL p50: ${fmt(ulP50)}${describe(ulP50)}`, - `UL p90: ${fmt(ulP90)}${describe(ulP90)}`, - `DL p50: ${fmt(dlP50)}${describe(dlP50)}`, - `DL p90: ${fmt(dlP90)}${describe(dlP90)}`, - ].join(" • "); + const tooltipLines = rttTooltipLines( + i + 1, + `${fmt(ulP50)}${describe(ulP50)}`, + `${fmt(ulP90)}${describe(ulP90)}`, + `${fmt(dlP50)}${describe(dlP50)}`, + `${fmt(dlP90)}${describe(dlP90)}`, + "Top row is upload, bottom row is download. Left is p50, right is p90.", + ); const quad = (v) => { if (v === null || v === undefined) { @@ -219,7 +344,7 @@ function heatmapRowQuadrants(quads, colorFn, formatValue, describeValue = null) // Quadrants: upload at top, download at bottom. Left=p50, right=p90. cells += ` -
+
${quad(ulP50)} ${quad(ulP90)} @@ -257,10 +382,13 @@ export function rttHeatRow(label, badge, blocks, colorFn, formatValue, link = nu `; } -function heatmapRowSplit(topValues, bottomValues, colorFn, formatValue) { +function heatmapRowSplit(topValues, bottomValues, colorFn, formatValue, options = {}) { const length = Array.isArray(topValues) && topValues.length ? topValues.length : (Array.isArray(bottomValues) && bottomValues.length ? bottomValues.length : 15); + const topLabel = options.topLabel || "Upload"; + const bottomLabel = options.bottomLabel || "Download"; + const meaning = options.meaning || "Top is upload, bottom is download."; let cells = ""; for (let i = 0; i < length; i++) { const top = topValues?.[i]; @@ -270,17 +398,20 @@ function heatmapRowSplit(topValues, bottomValues, colorFn, formatValue) { (top === null || top === undefined) && (bottom === null || bottom === undefined); if (allMissing) { - cells += `
`; + cells += `
`; continue; } const numOrNull = (v) => (v === null || v === undefined ? null : Number(v)); const fmt = (v) => formatValue(numOrNull(v)); - const title = [ - `Block ${i + 1}`, - `UL: ${fmt(top)}`, - `DL: ${fmt(bottom)}`, - ].join(" • "); + const tooltipLines = splitTooltipLines( + i + 1, + topLabel, + fmt(top), + bottomLabel, + fmt(bottom), + meaning, + ); const part = (v) => { if (v === null || v === undefined) { @@ -296,7 +427,7 @@ function heatmapRowSplit(topValues, bottomValues, colorFn, formatValue) { // Top = upload, bottom = download. cells += ` -
+
${part(top)} ${part(bottom)} @@ -307,7 +438,7 @@ function heatmapRowSplit(topValues, bottomValues, colorFn, formatValue) { return cells; } -function splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link = null) { +function splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link = null, options = {}) { const latestTop = latestValue(topValues); const latestBottom = latestValue(bottomValues); const formattedLatest = ` @@ -324,7 +455,7 @@ function splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValu
${labelMarkup}
${badge ? `${badge}` : ""}
- +
${formattedLatest}
`; @@ -333,11 +464,19 @@ function splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValu export function retransmitHeatRow(label, badge, blocks, colorFn, formatValue, link = null) { const topValues = blocks?.retransmit_up || []; const bottomValues = blocks?.retransmit_down || []; - return splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link); + return splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link, { + topLabel: "Upload retransmits", + bottomLabel: "Download retransmits", + meaning: "Top is upload, bottom is download. Higher percentages mean more TCP retransmits during this 15-minute block.", + }); } export function utilizationHeatRow(label, badge, blocks, colorFn, formatValue, link = null) { const topValues = blocks?.upload || []; const bottomValues = blocks?.download || []; - return splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link); + return splitHeatRow(label, badge, topValues, bottomValues, colorFn, formatValue, link, { + topLabel: "Upload utilization", + bottomLabel: "Download utilization", + meaning: "Top is upload, bottom is download. This shows how much of planned or available capacity was in use during this 15-minute block.", + }); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/operation_cards.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/operation_cards.js index ef7c6bc6..58017a41 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/operation_cards.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/operation_cards.js @@ -1,4 +1,5 @@ import {mkBadge} from "./bakery_shared"; +import {redactCell} from "../helpers/redact"; export function renderOperationCards(host, groups, options = {}) { const emptyText = options.emptyText || "No recent operations"; @@ -23,6 +24,7 @@ export function renderOperationCards(host, groups, options = {}) { const title = document.createElement("div"); title.classList.add("fw-semibold"); title.textContent = group.label || "Operation"; + redactCell(title); titleWrap.appendChild(title); const right = document.createElement("div"); @@ -43,6 +45,7 @@ export function renderOperationCards(host, groups, options = {}) { const summary = document.createElement("div"); summary.classList.add("small"); summary.textContent = group.summary; + redactCell(summary); if (group.summaryTitle) { summary.title = group.summaryTitle; } @@ -54,6 +57,9 @@ export function renderOperationCards(host, groups, options = {}) { const footerLeft = document.createElement("div"); footerLeft.textContent = group.footerLeft || ""; + if ((group.footerLeft || "").toString().trim()) { + redactCell(footerLeft); + } footer.appendChild(footerLeft); const footerRight = document.createElement("div"); @@ -79,6 +85,7 @@ export function renderOperationCards(host, groups, options = {}) { const stages = document.createElement("div"); stages.classList.add("small", "text-body-secondary"); stages.textContent = group.stages.join(" -> "); + redactCell(stages); card.appendChild(stages); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js index 4f7b75d7..4f814820 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js @@ -1,4 +1,5 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {redactCell} from "../helpers/redact"; import {toNumber} from "../lq_js_common/helpers/scaling"; import {get_ws_client} from "../pubsub/ws"; import {mkBadge} from "./bakery_shared"; @@ -816,6 +817,9 @@ export class TreeGuardActivityDashlet extends BaseDashlet { const reason = formatReason(entry.reason); tdReason.textContent = reason.label; if (reason.title) tdReason.title = reason.title; + if (reason.label) { + redactCell(tdReason); + } tr.appendChild(tdTime); tr.appendChild(tdEntity); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_decision_impact.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_decision_impact.js index 4d6e450b..9fdf7421 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_decision_impact.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_decision_impact.js @@ -1,4 +1,5 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {redactCell} from "../helpers/redact"; function chip(text, className) { const span = document.createElement("span"); @@ -237,6 +238,7 @@ export class TreeGuardDecisionImpactDashlet extends BaseDashlet { const text = document.createElement("div"); text.textContent = item.text; + redactCell(text); row.appendChild(text); this.alertSummaryEl.appendChild(row); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js index 933dd988..f2f7be85 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js @@ -1,4 +1,5 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {redactCell} from "../helpers/redact"; function clamp(n, min, max) { return Math.min(Math.max(n, min), max); @@ -160,6 +161,7 @@ function renderLastAction(summaryRaw) { wrap.appendChild(mkIcon(iconClass, iconExtra)); const span = document.createElement("span"); span.textContent = label; + redactCell(span); wrap.appendChild(span); return wrap; } @@ -348,6 +350,7 @@ export class TreeGuardStatusDashlet extends BaseDashlet { warnings.slice(0, 8).forEach((warning) => { const li = document.createElement("li"); li.textContent = warning; + redactCell(li); ul.appendChild(li); }); this.warningsEl.innerHTML = ""; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js index 81e4531c..70693f18 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js @@ -3,6 +3,7 @@ import { linkToExecutiveMetricRow, pollExecutiveHeatmapPage, } from "./executive_utils"; +import {disposeTooltipsWithin, enableTooltipsWithin} from "./lq_js_common/helpers/tooltips"; import { heatRow, rttHeatRow, @@ -10,6 +11,7 @@ import { utilizationHeatRow, formatLatest, colorByCapacity, + replaceHeatmapHtml, } from "./dashlets/executive_heatmap_shared"; import {colorByRttMs, colorByRetransmitPct} from "./helpers/color_scales"; import {toNumber} from "./lq_js_common/helpers/scaling"; @@ -193,7 +195,7 @@ function renderHeatmapTable(targetId, metricKey) { const generatedAt = toNumber(data?.generated_at_unix_ms, Date.now()); const body = (data?.rows || []).map((row) => renderMetricRow(cfg, metricKey, row)).join(""); - target.innerHTML = ` + const html = `
@@ -211,7 +213,13 @@ function renderHeatmapTable(targetId, metricKey) {
`; - attachPaginationHandlers(target, state); + replaceHeatmapHtml(target, html, (nextHtml) => { + disposeTooltipsWithin(target); + target.innerHTML = nextHtml; + target.__lqosLastHeatmapHtml = nextHtml; + attachPaginationHandlers(target, state); + enableTooltipsWithin(target); + }); }; window.addEventListener("colorBlindModeChanged", () => { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/redact.js b/src/rust/lqosd/src/node_manager/js_build/src/helpers/redact.js index b3295473..c2a3aaf3 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/helpers/redact.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/redact.js @@ -32,6 +32,19 @@ export function redactCell(cell) { cell.classList.add("redactable"); } +export function redactElement(el) { + if (!el) return el; + el.classList.add("redactable"); + return el; +} + +export function setRedactableText(el, text) { + if (!el) return el; + el.textContent = text ?? ""; + el.classList.add("redactable"); + return el; +} + function cssRedact() { let r = document.querySelector(':root'); if (isRedacted()) { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 7e17c236..6afc4f99 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -340,6 +340,10 @@ function displayParentName(name, parentType) { : name; } +function redactableHtml(text) { + return `${escapeHtml(text ?? "")}`; +} + function findNearestAncestorSiteIndex(indexMap, startIndex) { let currentIndex = startIndex; const visited = new Set(); @@ -1921,9 +1925,14 @@ class SiteMapPage { return; } this.detailsPanel.style.display = "block"; - this.detailsTitle.textContent = displayNodeName(props.name, props.nodeType); + this.detailsTitle.innerHTML = redactableHtml(displayNodeName(props.name, props.nodeType)); const parentName = displayParentName(props.parentName, props.parentType); - this.detailsSubtitle.textContent = `${String(props.nodeType || "").toUpperCase()}${parentName ? ` · parent ${parentName}` : ""}${props.inheritedCoords ? " · using parent site coordinates" : ""}`; + const subtitleBits = [ + escapeHtml(String(props.nodeType || "").toUpperCase()), + parentName ? ` · parent ${redactableHtml(parentName)}` : "", + props.inheritedCoords ? " · using parent site coordinates" : "", + ]; + this.detailsSubtitle.innerHTML = subtitleBits.join(""); const attachedAps = props.nodeType === "site" ? this.attachedApsForSite(props.key) : []; if (this.attachedList) { if (attachedAps.length === 0) { @@ -1931,7 +1940,7 @@ class SiteMapPage { } else { const preview = attachedAps .slice(0, 6) - .map((node) => `
${escapeHtml(displayNodeName(node.name, node.type))}
`) + .map((node) => `
${redactableHtml(displayNodeName(node.name, node.type))}
`) .join(""); const more = attachedAps.length > 6 ? `
+${attachedAps.length - 6} more APs attached to this site
` @@ -1984,7 +1993,7 @@ class SiteMapPage { renderUnmappedGroup(label, nodes) { const items = nodes .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => `
${escapeHtml(displayNodeName(node.name, node.type))}${node.parentName ? `
${escapeHtml(displayParentName(node.parentName, node.parentType))}
` : ""}
`) + .map((node) => `
${redactableHtml(displayNodeName(node.name, node.type))}${node.parentName ? `
${redactableHtml(displayParentName(node.parentName, node.parentType))}
` : ""}
`) .join(""); return `
${escapeHtml(label)}
${items}
`; } diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs index 1a779524..050116b2 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -1,7 +1,5 @@ use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; -use crate::throughput_tracker::flow_data::{ - ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country, -}; +use crate::throughput_tracker::flow_data::{ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country}; use lqos_utils::hash_to_i64; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use lqos_utils::unix_time::time_since_boot; @@ -81,17 +79,18 @@ pub struct CircuitTopAsnsQuery { pub hide_small: bool, } -/// Aggregated row for the circuit `Top ASNs` table. +/// Aggregated row for the circuit `Top ASNs` table, including recent rate, +/// median RTT/QoO, retransmit, and flow-count context for a circuit-local ASN. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct CircuitTopAsnRow { pub asn_name: String, pub asn_country: String, pub down_bps: u64, pub up_bps: u64, - pub bytes_sent_down: u64, - pub bytes_sent_up: u64, - pub packets_sent_down: u64, - pub packets_sent_up: u64, + pub rtt_down_nanos: u64, + pub rtt_up_nanos: u64, + pub qoo_down: Option, + pub qoo_up: Option, pub retransmit_down_pct: f64, pub retransmit_up_pct: f64, pub flow_count: usize, @@ -290,7 +289,11 @@ fn flow_snapshot_rows(circuit_id: &str) -> Vec { qoo_down: qoo.down, qoo_up: qoo.up, last_seen_nanos, - opacity: 1.0 - f64::min(1.0, last_seen_nanos as f64 / RECENT_CIRCUIT_FLOWS_WINDOW_NANOS as f64), + opacity: 1.0 + - f64::min( + 1.0, + last_seen_nanos as f64 / RECENT_CIRCUIT_FLOWS_WINDOW_NANOS as f64, + ), sort_rate_bps: current_rate as f64, }) }) @@ -307,7 +310,11 @@ fn compare_f64(left: f64, right: f64, asc: bool) -> Ordering { } fn compare_u64(left: u64, right: u64, asc: bool) -> Ordering { - if asc { left.cmp(&right) } else { right.cmp(&left) } + if asc { + left.cmp(&right) + } else { + right.cmp(&left) + } } fn compare_strings(left: &str, right: &str, asc: bool) -> Ordering { @@ -318,15 +325,59 @@ fn compare_strings(left: &str, right: &str, asc: bool) -> Ordering { } } +fn median_u64(values: &mut [u64]) -> Option { + if values.is_empty() { + return None; + } + values.sort_unstable(); + let midpoint = values.len() / 2; + if values.len() % 2 == 1 { + Some(values[midpoint]) + } else { + let left = values[midpoint - 1] as u128; + let right = values[midpoint] as u128; + Some(((left + right) / 2) as u64) + } +} + +fn median_f32(values: &mut [f32]) -> Option { + if values.is_empty() { + return None; + } + values.sort_by(|left, right| left.total_cmp(right)); + let midpoint = values.len() / 2; + if values.len() % 2 == 1 { + Some(values[midpoint]) + } else { + Some((values[midpoint - 1] + values[midpoint]) / 2.0) + } +} + fn sort_traffic_rows(rows: &mut [CircuitFlowSnapshotRow], sort_column: &str, sort_direction: &str) { let asc = sort_direction_is_asc(sort_direction); rows.sort_by(|a, b| { let primary = match sort_column { "protocol" => compare_strings(&a.protocol_name, &b.protocol_name, asc), - "bytes" => compare_u64(a.bytes_sent_down + a.bytes_sent_up, b.bytes_sent_down + b.bytes_sent_up, asc), - "packets" => compare_u64(a.packets_sent_down + a.packets_sent_up, b.packets_sent_down + b.packets_sent_up, asc), - "retransmits" => compare_f64(a.retransmit_down_pct + a.retransmit_up_pct, b.retransmit_down_pct + b.retransmit_up_pct, asc), - "rtt" => compare_u64(a.rtt_down_nanos + a.rtt_up_nanos, b.rtt_down_nanos + b.rtt_up_nanos, asc), + "bytes" => compare_u64( + a.bytes_sent_down + a.bytes_sent_up, + b.bytes_sent_down + b.bytes_sent_up, + asc, + ), + "packets" => compare_u64( + a.packets_sent_down + a.packets_sent_up, + b.packets_sent_down + b.packets_sent_up, + asc, + ), + "retransmits" => compare_f64( + a.retransmit_down_pct + a.retransmit_up_pct, + b.retransmit_down_pct + b.retransmit_up_pct, + asc, + ), + "rtt" => compare_u64( + a.rtt_down_nanos + a.rtt_up_nanos, + b.rtt_down_nanos + b.rtt_up_nanos, + asc, + ), "qoo" => compare_f64( a.qoo_down.unwrap_or(0.0) as f64 + a.qoo_up.unwrap_or(0.0) as f64, b.qoo_down.unwrap_or(0.0) as f64 + b.qoo_up.unwrap_or(0.0) as f64, @@ -358,7 +409,10 @@ pub fn circuit_flow_counts(circuit_id: &str) -> (usize, usize) { pub fn circuit_traffic_flows_page(query: &CircuitTrafficFlowsQuery) -> CircuitTrafficFlowsPage { let mut rows = flow_snapshot_rows(&query.circuit); if query.hide_small { - rows.retain(|row| row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS); + rows.retain(|row| { + row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS + || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS + }); } sort_traffic_rows(&mut rows, &query.sort_column, &query.sort_direction); @@ -408,8 +462,10 @@ pub fn circuit_top_asns_data(query: &CircuitTopAsnsQuery) -> CircuitTopAsnsData asn_country: String, down_bps: u64, up_bps: u64, - bytes_sent_down: u64, - bytes_sent_up: u64, + rtt_down_nanos: Vec, + rtt_up_nanos: Vec, + qoo_down: Vec, + qoo_up: Vec, packets_sent_down: u64, packets_sent_up: u64, tcp_retransmits_down: u64, @@ -419,55 +475,72 @@ pub fn circuit_top_asns_data(query: &CircuitTopAsnsQuery) -> CircuitTopAsnsData let mut rows = flow_snapshot_rows(&query.circuit); if query.hide_small { - rows.retain(|row| row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS); + rows.retain(|row| { + row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS + || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS + }); } - let mut buckets: fxhash::FxHashMap<(String, String), AsnBucket> = fxhash::FxHashMap::default(); + let mut buckets: fxhash::FxHashMap = fxhash::FxHashMap::default(); for row in rows { - let key = ( - if row.asn_name.trim().is_empty() { + let bucket = buckets.entry(row.asn_id).or_insert_with(|| AsnBucket { + asn_name: if row.asn_name.trim().is_empty() { "Unknown ASN".to_string() } else { row.asn_name.clone() }, - row.asn_country.clone(), - ); - let bucket = buckets.entry(key.clone()).or_insert_with(|| AsnBucket { - asn_name: key.0.clone(), - asn_country: key.1.clone(), + asn_country: row.asn_country.clone(), down_bps: 0, up_bps: 0, - bytes_sent_down: 0, - bytes_sent_up: 0, + rtt_down_nanos: Vec::new(), + rtt_up_nanos: Vec::new(), + qoo_down: Vec::new(), + qoo_up: Vec::new(), packets_sent_down: 0, packets_sent_up: 0, tcp_retransmits_down: 0, tcp_retransmits_up: 0, flow_count: 0, }); + if bucket.asn_name == "Unknown ASN" && !row.asn_name.trim().is_empty() { + bucket.asn_name = row.asn_name.clone(); + } + if bucket.asn_country.trim().is_empty() && !row.asn_country.trim().is_empty() { + bucket.asn_country = row.asn_country.clone(); + } bucket.down_bps += row.down_bps as u64; bucket.up_bps += row.up_bps as u64; - bucket.bytes_sent_down += row.bytes_sent_down; - bucket.bytes_sent_up += row.bytes_sent_up; bucket.packets_sent_down += row.packets_sent_down; bucket.packets_sent_up += row.packets_sent_up; bucket.tcp_retransmits_down += row.tcp_retransmits_down as u64; bucket.tcp_retransmits_up += row.tcp_retransmits_up as u64; + if row.rtt_down_nanos > 0 { + bucket.rtt_down_nanos.push(row.rtt_down_nanos); + } + if row.rtt_up_nanos > 0 { + bucket.rtt_up_nanos.push(row.rtt_up_nanos); + } + if let Some(qoo_down) = row.qoo_down { + bucket.qoo_down.push(qoo_down); + } + if let Some(qoo_up) = row.qoo_up { + bucket.qoo_up.push(qoo_up); + } bucket.flow_count += 1; } let total_asns = buckets.len(); let mut bucket_rows: Vec = buckets .into_values() - .map(|row| CircuitTopAsnRow { + .map(|mut row| CircuitTopAsnRow { asn_name: row.asn_name, asn_country: row.asn_country, down_bps: row.down_bps, up_bps: row.up_bps, - bytes_sent_down: row.bytes_sent_down, - bytes_sent_up: row.bytes_sent_up, - packets_sent_down: row.packets_sent_down, - packets_sent_up: row.packets_sent_up, + rtt_down_nanos: median_u64(&mut row.rtt_down_nanos).unwrap_or_default(), + rtt_up_nanos: median_u64(&mut row.rtt_up_nanos).unwrap_or_default(), + qoo_down: median_f32(&mut row.qoo_down), + qoo_up: median_f32(&mut row.qoo_up), retransmit_down_pct: if row.packets_sent_down > 0 { row.tcp_retransmits_down as f64 / row.packets_sent_down as f64 } else { @@ -516,3 +589,26 @@ pub fn circuit_flow_sankey_rows(circuit_id: &str) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::{median_f32, median_u64}; + + #[test] + fn median_u64_handles_even_and_odd_lengths() { + let mut odd = vec![30_u64, 10, 20]; + assert_eq!(median_u64(&mut odd), Some(20)); + + let mut even = vec![40_u64, 10, 30, 20]; + assert_eq!(median_u64(&mut even), Some(25)); + } + + #[test] + fn median_f32_handles_even_and_odd_lengths() { + let mut odd = vec![30.0_f32, 10.0, 20.0]; + assert_eq!(median_f32(&mut odd), Some(20.0)); + + let mut even = vec![40.0_f32, 10.0, 30.0, 20.0]; + assert_eq!(median_f32(&mut even), Some(25.0)); + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/lts.rs b/src/rust/lqosd/src/node_manager/local_api/lts.rs index 89c690bf..e7214286 100644 --- a/src/rust/lqosd/src/node_manager/local_api/lts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/lts.rs @@ -117,7 +117,10 @@ fn classify_signup_check_response(response: SignupCheckResponse) -> Result Result<(), StatusCode> { +async fn apply_insight_license( + license_key: String, + restart_lqos_api: bool, +) -> Result<(), StatusCode> { let license_key = license_key.trim().to_string(); if license_key.is_empty() { return Err(StatusCode::BAD_GATEWAY); diff --git a/src/rust/lqosd/src/node_manager/static2/config_sonar.html b/src/rust/lqosd/src/node_manager/static2/config_sonar.html index ee828ee8..073d8c38 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_sonar.html +++ b/src/rust/lqosd/src/node_manager/static2/config_sonar.html @@ -53,6 +53,34 @@
Comma-separated list of active status IDs
+
+ + +
Comma-separated recurring service names that should never be treated as internet plans.
+
+ +
+ +
+ + + + + + + + + + + +
EnabledExact Service NameDownload MbpsUpload Mbps
+
+ +
Used only when an active account has no usable DATA service. Match names exactly and whitelist only internet plans.
+
+
diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index d15f447e..eeb3f5dd 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -445,7 +445,8 @@ async fn receive_channel_message( } } WsRequest::CircuitDevices { circuit } => { - let devices = circuit_devices_snapshot(&circuit, request_state.private_state.bus_tx()).await; + let devices = + circuit_devices_snapshot(&circuit, request_state.private_state.bus_tx()).await; let response = circuit_devices_result(circuit, devices); if send_ws_response(&tx, response).await { return true; diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs index db1110f1..ac8acc40 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs @@ -35,7 +35,10 @@ fn qoo_score_for_circuit(circuit: &str) -> Option { }) } -fn weighted_directional_rtt_p50_nanos(devices: &[Circuit], direction: fn(&Circuit) -> (u64, Option)) -> Option { +fn weighted_directional_rtt_p50_nanos( + devices: &[Circuit], + direction: fn(&Circuit) -> (u64, Option), +) -> Option { let mut weighted_entries = Vec::new(); let mut fallback_values = Vec::new(); @@ -52,7 +55,10 @@ fn weighted_directional_rtt_p50_nanos(devices: &[Circuit], direction: fn(&Circui if !weighted_entries.is_empty() { weighted_entries.sort_by_key(|(rtt, _)| *rtt); - let total_weight: u128 = weighted_entries.iter().map(|(_, weight)| *weight as u128).sum(); + let total_weight: u128 = weighted_entries + .iter() + .map(|(_, weight)| *weight as u128) + .sum(); let threshold = total_weight / 2; let mut running = 0_u128; for (rtt, weight) in weighted_entries { @@ -76,11 +82,13 @@ fn weighted_directional_rtt_p50_nanos(devices: &[Circuit], direction: fn(&Circui } fn summarize_circuit_devices(circuit: &str, devices: &[Circuit]) -> CircuitSummaryData { - let bytes_per_second = devices.iter().fold(DownUpOrder::default(), |mut acc, device| { - acc.down += device.bytes_per_second.down; - acc.up += device.bytes_per_second.up; - acc - }); + let bytes_per_second = devices + .iter() + .fold(DownUpOrder::default(), |mut acc, device| { + acc.down += device.bytes_per_second.down; + acc.up += device.bytes_per_second.up; + acc + }); let tcp_retransmit_sample = down_up_retransmit_sample( DownUpOrder { diff --git a/src/rust/uisp_integration/src/strategies/ap_site.rs b/src/rust/uisp_integration/src/strategies/ap_site.rs index c9e575f2..42696274 100644 --- a/src/rust/uisp_integration/src/strategies/ap_site.rs +++ b/src/rust/uisp_integration/src/strategies/ap_site.rs @@ -383,6 +383,112 @@ pub(crate) async fn find_troublesome_sites(data: &UispData) -> anyhow::Result anyhow::Result> { let mut result = HashSet::new(); for client in data.find_client_sites() { diff --git a/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs b/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs index 10946375..bd06db65 100644 --- a/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs +++ b/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs @@ -219,6 +219,8 @@ fn traverse( #[cfg(test)] mod tests { use super::*; + use crate::uisp_types::{UispDevice, UispSite, UispSiteType}; + use std::collections::HashSet; #[test] fn test_fractional_csv_serialization() { @@ -363,4 +365,54 @@ mod tests { println!("✅ Rate safeguard tests passed!"); } + + #[test] + fn ignored_only_client_device_produces_no_shaped_rows() { + let config = Config::default(); + let sites = vec![ + UispSite { + id: "root-site".to_string(), + name: "Root Site".to_string(), + site_type: UispSiteType::Site, + max_down_mbps: 1000, + max_up_mbps: 1000, + ..Default::default() + }, + UispSite { + id: "client-site".to_string(), + name: "Client Site".to_string(), + site_type: UispSiteType::Client, + max_down_mbps: 100, + max_up_mbps: 50, + selected_parent: Some(0), + device_indices: vec![0], + ..Default::default() + }, + ]; + let devices = vec![UispDevice { + id: "device-1".to_string(), + name: "CPE 1".to_string(), + mac: "".to_string(), + role: None, + wireless_mode: None, + site_id: "client-site".to_string(), + download: 100, + upload: 50, + ipv4: HashSet::new(), + ipv6: HashSet::new(), + negotiated_ethernet_mbps: None, + negotiated_ethernet_interface: None, + }]; + + let mut shaped_devices = Vec::new(); + let mut ethernet_advisories = Vec::new(); + let mut outputs = ShapedDeviceOutputs { + shaped_devices: &mut shaped_devices, + ethernet_advisories: &mut ethernet_advisories, + }; + + traverse(&sites, 0, 0, &devices, &mut outputs, &config, 0); + + assert!(outputs.shaped_devices.is_empty()); + } } diff --git a/src/rust/uisp_integration/src/strategies/full2/net_json_parent.rs b/src/rust/uisp_integration/src/strategies/full2/net_json_parent.rs index 3877e289..8951f9ef 100644 --- a/src/rust/uisp_integration/src/strategies/full2/net_json_parent.rs +++ b/src/rust/uisp_integration/src/strategies/full2/net_json_parent.rs @@ -36,7 +36,7 @@ pub fn walk_parents( } => { map.insert("type".into(), "Site".into()); map.insert("uisp_site".into(), id.clone().into()); - map.insert("parent_site".into(), name.clone().into()); + map.insert("parent_site".into(), node_info.parent_name.clone().into()); if let Some(latitude) = latitude && let Some(number) = serde_json::Number::from_f64(*latitude as f64) { @@ -54,11 +54,11 @@ pub fn walk_parents( GraphMapping::AccessPoint { name: _, id, - site_name, + site_name: _, .. } => { map.insert("type".into(), "AP".into()); - map.insert("parent_site".into(), site_name.clone().into()); + map.insert("parent_site".into(), node_info.parent_name.clone().into()); map.insert("uisp_device".into(), id.clone().into()); } } @@ -80,3 +80,89 @@ pub fn walk_parents( map } + +#[cfg(test)] +mod tests { + use super::{NetJsonParent, walk_parents}; + use crate::strategies::full2::graph_mapping::GraphMapping; + use std::collections::{HashMap, HashSet}; + + #[test] + fn emits_graph_parent_site_for_sites_and_access_points() { + let root_mapping = GraphMapping::Root { + name: "WestRedd-SiteRouter".to_string(), + id: "site-root".to_string(), + latitude: None, + longitude: None, + }; + let child_site_mapping = GraphMapping::Site { + name: "MDC El Paso".to_string(), + id: "site-mdc".to_string(), + latitude: None, + longitude: None, + }; + let child_ap_mapping = GraphMapping::AccessPoint { + name: "Core-WestRedd".to_string(), + id: "device-core-westredd".to_string(), + site_name: "MDC El Paso".to_string(), + download_mbps: 5000, + upload_mbps: 5000, + }; + + let mut parents = HashMap::new(); + parents.insert( + "MDC El Paso".to_string(), + NetJsonParent { + parent_name: "WestRedd-SiteRouter".to_string(), + mapping: &child_site_mapping, + download: 5000, + upload: 5000, + }, + ); + parents.insert( + "Core-WestRedd".to_string(), + NetJsonParent { + parent_name: "MDC El Paso".to_string(), + mapping: &child_ap_mapping, + download: 5000, + upload: 5000, + }, + ); + + let root_name = "WestRedd-SiteRouter".to_string(); + let root_node = NetJsonParent { + parent_name: "WestRedd".to_string(), + mapping: &root_mapping, + download: 5000, + upload: 5000, + }; + + let tree = walk_parents(&parents, &root_name, &root_node, &mut HashSet::new()); + assert_eq!( + tree.get("parent_site").and_then(|value| value.as_str()), + Some("WestRedd") + ); + + let site = tree + .get("children") + .and_then(|value| value.as_object()) + .and_then(|children| children.get("MDC El Paso")) + .and_then(|value| value.as_object()) + .expect("site child should be present"); + assert_eq!( + site.get("parent_site").and_then(|value| value.as_str()), + Some("WestRedd-SiteRouter") + ); + + let ap = site + .get("children") + .and_then(|value| value.as_object()) + .and_then(|children| children.get("Core-WestRedd")) + .and_then(|value| value.as_object()) + .expect("ap child should be present"); + assert_eq!( + ap.get("parent_site").and_then(|value| value.as_str()), + Some("MDC El Paso") + ); + } +} diff --git a/src/testGraph.py b/src/testGraph.py index 132eb389..cf549fb7 100644 --- a/src/testGraph.py +++ b/src/testGraph.py @@ -68,6 +68,22 @@ class TestGraph(unittest.TestCase): self.assertEqual(graph.nodes[2].parentIndex, 1) self.assertEqual(graph.nodes[0].parentIndex, 0) + def test_add_child_by_named_parent_survives_reparent(self): + """ + Ensures addNodeAsChild persists parent identity so later reparenting + keeps the child attached to the intended parent. + """ + from integrationCommon import NetworkGraph, NetworkNode, NodeType + graph = NetworkGraph() + graph.addRawNode(NetworkNode("Site")) + graph.addNodeAsChild("Site", NetworkNode("Client", type=NodeType.client)) + + graph._NetworkGraph__reparentById() + + self.assertEqual(len(graph.nodes), 3) + self.assertEqual(graph.nodes[2].parentId, "Site") + self.assertEqual(graph.nodes[2].parentIndex, 1) + def test_reparent_by_name(self): """ Tests that re-parenting a tree by name is functional @@ -171,6 +187,25 @@ class TestGraph(unittest.TestCase): self.assertEqual(graph.nodes[7].type, NodeType.site) self.assertEqual(graph.nodes[7].id, "Client 3_gen") + def test_generated_site_keeps_original_parent(self): + """ + Ensures generated sites created from relay-style clients retain the + original non-root parent after the reparent pass. + """ + from integrationCommon import NetworkGraph, NetworkNode, NodeType + graph = NetworkGraph() + graph.addRawNode(NetworkNode("Parent Site")) + graph.addRawNode(NetworkNode("Relay Client", parentId="Parent Site", type=NodeType.client)) + graph.addRawNode(NetworkNode("Child Client", parentId="Relay Client", type=NodeType.client)) + + graph._NetworkGraph__reparentById() + graph._NetworkGraph__promoteClientsWithChildren() + graph._NetworkGraph__clientsWithChildrenToSites() + + generated_site = next(node for node in graph.nodes if node.id == "Relay Client_gen") + self.assertEqual(generated_site.parentId, "Parent Site") + self.assertEqual(generated_site.parentIndex, 1) + def test_find_unconnected(self): """ Tests traversing a tree and finding nodes that diff --git a/src/test_integration_common_ignore_subnets.py b/src/test_integration_common_ignore_subnets.py new file mode 100644 index 00000000..714df8fd --- /dev/null +++ b/src/test_integration_common_ignore_subnets.py @@ -0,0 +1,138 @@ +import csv +import importlib +import os +import sys +import tempfile +import types +import unittest + + +def install_common_stubs(): + lqlib = types.ModuleType("liblqos_python") + lqlib.allowed_subnets = lambda: ["0.0.0.0/0", "::/0"] + lqlib.ignore_subnets = lambda: [] + lqlib.generated_pn_download_mbps = lambda: 1000 + lqlib.generated_pn_upload_mbps = lambda: 1000 + lqlib.circuit_name_use_address = lambda: False + lqlib.upstream_bandwidth_capacity_download_mbps = lambda: 1000 + lqlib.upstream_bandwidth_capacity_upload_mbps = lambda: 1000 + lqlib.find_ipv6_using_mikrotik = lambda: False + lqlib.exclude_sites = lambda: [] + lqlib.bandwidth_overhead_factor = lambda: 1.0 + lqlib.committed_bandwidth_multiplier = lambda: 1.0 + lqlib.exception_cpes = lambda: [] + lqlib.promote_to_root_list = lambda: [] + lqlib.client_bandwidth_multiplier = lambda: 1.0 + sys.modules["liblqos_python"] = lqlib + + +install_common_stubs() +integrationCommon = importlib.import_module("integrationCommon") + + +class TestIntegrationCommonIgnoreSubnets(unittest.TestCase): + def setUp(self): + integrationCommon.allowed_subnets = lambda: ["0.0.0.0/0", "::/0"] + integrationCommon.ignore_subnets = lambda: [] + + def _build_client_with_device(self, ipv4=None, ipv6=None): + net = integrationCommon.NetworkGraph() + net.addRawNode( + integrationCommon.NetworkNode( + id="client-1", + displayName="Client 1", + type=integrationCommon.NodeType.client, + download=100, + upload=50, + ) + ) + net.addRawNode( + integrationCommon.NetworkNode( + id="device-1", + displayName="Device 1", + type=integrationCommon.NodeType.device, + parentId="client-1", + ipv4=ipv4 or [], + ipv6=ipv6 or [], + ) + ) + return net + + def test_ignored_only_device_removes_entire_circuit(self): + integrationCommon.ignore_subnets = lambda: ["100.64.0.0/10"] + net = self._build_client_with_device(ipv4=["100.64.1.10/32"]) + + net.prepareTree() + + self.assertEqual([node.id for node in net.nodes], ["FakeRoot"]) + + with tempfile.TemporaryDirectory() as tmpdir: + old_cwd = os.getcwd() + try: + os.chdir(tmpdir) + net.createShapedDevices() + with open("ShapedDevices.csv", newline="") as csvfile: + rows = list(csv.reader(csvfile)) + finally: + os.chdir(old_cwd) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "Circuit ID") + + def test_mixed_device_ips_keep_only_permitted_addresses(self): + integrationCommon.ignore_subnets = lambda: ["100.64.0.0/10"] + net = self._build_client_with_device( + ipv4=["100.64.1.10/32", "203.0.113.10/32"], + ipv6=["2001:db8::10/128"], + ) + + net.prepareTree() + + self.assertEqual(len(net.nodes), 3) + device = next(node for node in net.nodes if node.id == "device-1") + self.assertEqual(device.ipv4, ["203.0.113.10/32"]) + self.assertEqual(device.ipv6, ["2001:db8::10/128"]) + + with tempfile.TemporaryDirectory() as tmpdir: + old_cwd = os.getcwd() + try: + os.chdir(tmpdir) + net.createShapedDevices() + with open("ShapedDevices.csv", newline="") as csvfile: + rows = list(csv.reader(csvfile)) + finally: + os.chdir(old_cwd) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1][0], "client-1") + self.assertIn("203.0.113.10/32", rows[1][6]) + self.assertNotIn("100.64.1.10/32", rows[1][6]) + + def test_prune_does_not_require_ip_to_be_in_allowed_subnets(self): + integrationCommon.allowed_subnets = lambda: ["10.0.0.0/8"] + integrationCommon.ignore_subnets = lambda: [] + net = self._build_client_with_device(ipv4=["203.0.113.10/32"]) + + net.prepareTree() + + self.assertEqual(len(net.nodes), 3) + device = next(node for node in net.nodes if node.id == "device-1") + self.assertEqual(device.ipv4, ["203.0.113.10/32"]) + + with tempfile.TemporaryDirectory() as tmpdir: + old_cwd = os.getcwd() + try: + os.chdir(tmpdir) + net.createShapedDevices() + with open("ShapedDevices.csv", newline="") as csvfile: + rows = list(csv.reader(csvfile)) + finally: + os.chdir(old_cwd) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1][0], "client-1") + self.assertIn("203.0.113.10/32", rows[1][6]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_integration_sonar.py b/src/test_integration_sonar.py new file mode 100644 index 00000000..cb1c2268 --- /dev/null +++ b/src/test_integration_sonar.py @@ -0,0 +1,418 @@ +import importlib +import sys +import types +import unittest +import requests + + +def install_sonar_stubs(): + lqlib = types.ModuleType("liblqos_python") + lqlib.allowed_subnets = lambda: ["0.0.0.0/0"] + lqlib.ignore_subnets = lambda: [] + lqlib.generated_pn_download_mbps = lambda: 1000 + lqlib.generated_pn_upload_mbps = lambda: 1000 + lqlib.circuit_name_use_address = lambda: False + lqlib.upstream_bandwidth_capacity_download_mbps = lambda: 1000 + lqlib.upstream_bandwidth_capacity_upload_mbps = lambda: 1000 + lqlib.find_ipv6_using_mikrotik = lambda: False + lqlib.exclude_sites = lambda: [] + lqlib.bandwidth_overhead_factor = lambda: 1.0 + lqlib.committed_bandwidth_multiplier = lambda: 1.0 + lqlib.exception_cpes = lambda: [] + lqlib.promote_to_root_list = lambda: [] + lqlib.client_bandwidth_multiplier = lambda: 1.0 + lqlib.sonar_api_key = lambda: "" + lqlib.sonar_api_url = lambda: "http://example.invalid" + lqlib.snmp_community = lambda: "public" + lqlib.sonar_airmax_ap_model_ids = lambda: [] + lqlib.sonar_ltu_ap_model_ids = lambda: [] + lqlib.sonar_active_status_ids = lambda: [] + lqlib.sonar_recurring_service_rates = lambda: [] + lqlib.sonar_recurring_excluded_service_names = lambda: [] + sys.modules["liblqos_python"] = lqlib + + +install_sonar_stubs() +integrationSonar = importlib.import_module("integrationSonar") + + +def inventory_item(item_id, name, ips=None, mac=""): + ips = ips or [] + field_entities = [] + if mac: + field_entities.append({ + "value": mac, + "ip_assignments": {"entities": []}, + "inventory_model_field": { + "id": 1, + "name": "MAC Address", + "primary": True, + }, + }) + return { + "id": item_id, + "inventory_model": {"name": name}, + "inventory_model_field_data": {"entities": field_entities}, + "ip_assignments": { + "entities": [{"subnet": subnet} for subnet in ips] + }, + } + + +def sonar_account(address_items=None, radius_accounts=None): + return { + "addresses": { + "entities": [ + { + "line1": "1 Main", + "line2": "", + "city": "Town", + "subdivision": "TX", + "inventory_items": {"entities": address_items or []}, + } + ] + }, + "radius_accounts": {"entities": radius_accounts or []}, + "all_account_services": {"entities": []}, + } + + +def radius_account(radius_id, ips): + return { + "id": radius_id, + "ip_assignments": { + "entities": [{"subnet": subnet} for subnet in ips] + }, + } + + +class TestIntegrationSonarDevices(unittest.TestCase): + def setUp(self): + integrationSonar.sonar_recurring_service_rates = lambda: [] + integrationSonar.sonar_recurring_excluded_service_names = lambda: [] + + def test_inventory_only_devices_keep_existing_shape(self): + devices = integrationSonar.buildAccountDevices( + sonar_account( + address_items=[ + inventory_item( + 42, + "ONU", + ips=["100.64.1.10/32"], + mac="AA:BB:CC:DD:EE:FF", + ) + ] + ) + ) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["id"], "sonar:device:42") + self.assertEqual(devices[0]["ips"], ["100.64.1.10/32"]) + self.assertEqual(devices[0]["mac"], "AA:BB:CC:DD:EE:FF") + + def test_radius_only_accounts_emit_stable_radius_devices(self): + devices = integrationSonar.buildAccountDevices( + sonar_account( + radius_accounts=[ + radius_account(77, ["100.64.2.20/32"]) + ] + ) + ) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["id"], "sonar:radius-account:77") + self.assertEqual(devices[0]["name"], "Radius Account 77") + self.assertEqual(devices[0]["ips"], ["100.64.2.20/32"]) + self.assertEqual(devices[0]["mac"], "") + + def test_radius_ips_do_not_duplicate_inventory_ips(self): + devices = integrationSonar.buildAccountDevices( + sonar_account( + address_items=[ + inventory_item( + 42, + "ONU", + ips=["100.64.3.30/32"], + mac="AA:BB:CC:DD:EE:01", + ) + ], + radius_accounts=[ + radius_account(88, ["100.64.3.30/32", "100.64.3.31/32"]) + ], + ) + ) + + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0]["id"], "sonar:device:42") + self.assertEqual(devices[1]["id"], "sonar:radius-account:88") + self.assertEqual(devices[1]["ips"], ["100.64.3.31/32"]) + + def test_inventory_mac_devices_are_preserved_when_radius_supplies_ips(self): + devices = integrationSonar.buildAccountDevices( + sonar_account( + address_items=[ + inventory_item( + 42, + "ONU", + ips=[], + mac="AA:BB:CC:DD:EE:02", + ) + ], + radius_accounts=[ + radius_account(99, ["100.64.4.40/32"]) + ], + ) + ) + + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0]["id"], "sonar:device:42") + self.assertEqual(devices[0]["ips"], []) + self.assertEqual(devices[0]["mac"], "AA:BB:CC:DD:EE:02") + self.assertEqual(devices[1]["id"], "sonar:radius-account:99") + self.assertEqual(devices[1]["ips"], ["100.64.4.40/32"]) + + def test_child_account_inherits_parent_address_when_missing_own_address(self): + child = sonar_account( + address_items=[], + radius_accounts=[radius_account(100, ["100.64.5.50/32"])], + ) + child["addresses"] = {"entities": []} + child["id"] = 500 + child["name"] = "Suite 500" + child["account_services"] = { + "entities": [{ + "service": { + "data_service_detail": { + "download_speed_kilobits_per_second": 100000, + "upload_speed_kilobits_per_second": 20000, + } + } + }] + } + record = integrationSonar.buildAccountRecord(child, fallback_address="Parent Address") + + self.assertIsNotNone(record) + self.assertEqual(record["address"], "Parent Address") + self.assertEqual(record["id"], "sonar:account:500") + + def test_child_accounts_are_added_without_duplicate_top_level_records(self): + top_level = sonar_account( + address_items=[ + inventory_item( + 42, + "ONU", + ips=["100.64.6.60/32"], + mac="AA:BB:CC:DD:EE:06", + ) + ] + ) + top_level["id"] = 600 + top_level["name"] = "Parent" + top_level["account_services"] = { + "entities": [{ + "service": { + "data_service_detail": { + "download_speed_kilobits_per_second": 100000, + "upload_speed_kilobits_per_second": 20000, + } + } + }] + } + + child_only = sonar_account( + address_items=[], + radius_accounts=[radius_account(6010, ["100.64.6.61/32"])], + ) + child_only["addresses"] = {"entities": []} + child_only["id"] = 601 + child_only["name"] = "Child Only" + child_only["account_services"] = { + "entities": [{ + "service": { + "data_service_detail": { + "download_speed_kilobits_per_second": 50000, + "upload_speed_kilobits_per_second": 10000, + } + } + }] + } + + child_duplicate = sonar_account( + address_items=[], + radius_accounts=[radius_account(6020, ["100.64.6.62/32"])], + ) + child_duplicate["addresses"] = {"entities": []} + child_duplicate["id"] = 602 + child_duplicate["name"] = "Child Duplicate" + child_duplicate["account_services"] = { + "entities": [{ + "service": { + "data_service_detail": { + "download_speed_kilobits_per_second": 50000, + "upload_speed_kilobits_per_second": 10000, + } + } + }] + } + + top_level["child_accounts"] = {"entities": [child_only, child_duplicate]} + + duplicate_top_level = sonar_account( + address_items=[], + radius_accounts=[radius_account(6021, ["100.64.6.63/32"])], + ) + duplicate_top_level["id"] = 602 + duplicate_top_level["name"] = "Child Duplicate" + duplicate_top_level["account_services"] = { + "entities": [{ + "service": { + "data_service_detail": { + "download_speed_kilobits_per_second": 75000, + "upload_speed_kilobits_per_second": 15000, + } + } + }] + } + duplicate_top_level["child_accounts"] = {"entities": []} + + accounts = integrationSonar.buildAccountsFromSonarEntities([top_level, duplicate_top_level]) + + self.assertEqual(len(accounts), 3) + self.assertEqual([account["raw_id"] for account in accounts], [600, 602, 601]) + duplicate = next(account for account in accounts if account["raw_id"] == 602) + self.assertEqual(duplicate["download"], 75.0) + self.assertEqual(duplicate["address"], "1 Main, Town, TX") + + def test_recurring_rate_rule_is_used_when_data_service_is_missing(self): + integrationSonar.sonar_recurring_service_rates = lambda: [ + (True, "1 Gig Bulk Tenant Service", 1000.0, 1000.0) + ] + account = sonar_account( + radius_accounts=[radius_account(7000, ["100.64.7.70/32"])], + ) + account["id"] = 700 + account["name"] = "Bulk Tenant" + account["account_services"] = {"entities": []} + account["all_account_services"] = { + "entities": [{ + "service": { + "id": 1, + "name": "1 Gig Bulk Tenant Service", + "type": "RECURRING", + "enabled": True, + "data_service_detail": None, + } + }] + } + + record = integrationSonar.buildAccountRecord(account) + + self.assertIsNotNone(record) + self.assertEqual(record["download"], 1000.0) + self.assertEqual(record["upload"], 1000.0) + + def test_recurring_exclusion_blocks_recurring_fallback(self): + integrationSonar.sonar_recurring_service_rates = lambda: [ + (True, "Equipment Rental", 1000.0, 1000.0) + ] + integrationSonar.sonar_recurring_excluded_service_names = lambda: ["Equipment Rental"] + account = sonar_account( + radius_accounts=[radius_account(7100, ["100.64.7.71/32"])], + ) + account["id"] = 701 + account["name"] = "Rental" + account["account_services"] = {"entities": []} + account["all_account_services"] = { + "entities": [{ + "service": { + "id": 1, + "name": "Equipment Rental", + "type": "RECURRING", + "enabled": True, + "data_service_detail": None, + } + }] + } + + record = integrationSonar.buildAccountRecord(account) + + self.assertIsNone(record) + + +class FakeResponse: + def __init__(self, payload): + self._payload = payload + self.status_code = 200 + self.headers = {} + self.text = "" + + def json(self): + return self._payload + + +class FakeSession: + def __init__(self, outcomes): + self.outcomes = list(outcomes) + self.calls = [] + + def post(self, url, json, timeout): + self.calls.append({ + "url": url, + "json": json, + "timeout": timeout, + }) + outcome = self.outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +class TestIntegrationSonarTimeoutHandling(unittest.TestCase): + def setUp(self): + self.original_session = integrationSonar._SONAR_SESSION + self.original_sleep = integrationSonar.time.sleep + self.sleep_calls = [] + integrationSonar.time.sleep = lambda seconds: self.sleep_calls.append(seconds) + + def tearDown(self): + integrationSonar._SONAR_SESSION = self.original_session + integrationSonar.time.sleep = self.original_sleep + + def test_paginated_request_retries_read_timeout(self): + fake_session = FakeSession([ + requests.exceptions.ReadTimeout("timed out"), + FakeResponse({ + "data": { + "accounts": { + "entities": [{"id": "1"}], + "page_info": {"total_pages": 1}, + } + } + }), + ]) + integrationSonar._SONAR_SESSION = fake_session + + entities = integrationSonar.sonarPaginatedRequest("query test {}", {}) + + self.assertEqual(entities, [{"id": "1"}]) + self.assertEqual(len(fake_session.calls), 2) + self.assertEqual(fake_session.calls[0]["timeout"], (10, 60)) + self.assertEqual(self.sleep_calls, [2]) + + def test_paginated_request_raises_after_retry_budget_exhausted(self): + fake_session = FakeSession([ + requests.exceptions.ReadTimeout("timed out 1"), + requests.exceptions.ReadTimeout("timed out 2"), + requests.exceptions.ReadTimeout("timed out 3"), + ]) + integrationSonar._SONAR_SESSION = fake_session + + with self.assertRaises(RuntimeError) as ctx: + integrationSonar.sonarPaginatedRequest("query test {}", {}) + + self.assertIn("after 3 attempts", str(ctx.exception)) + self.assertEqual(self.sleep_calls, [2, 5]) + + +if __name__ == "__main__": + unittest.main()