Merge remote-tracking branch 'origin/develop' into fix/shaped_devices_ip_repr

This commit is contained in:
Herbert Wolverson committed 2026-04-03 16:29:57 -05:00
commit d35629cf31
43 files changed
+2819 -223

No files matched your search

+1 -1
View File
@@ -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):
+14
View File
@@ -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).
+4
View File
@@ -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:<id>` and `sonar:device:<id>`) 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
+5 -6
View File
@@ -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
+90 -23
View File
@@ -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.
+24 -2
View File
@@ -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(
+381 -48
View File
@@ -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
+2
View File
@@ -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
+661
View File
@@ -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 [ ]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+1 -1
View File
@@ -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};
+29 -6
View File
@@ -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<BakeryCommands> },
RebuildRequired {
summary: String,
details: Option<StructuralSiteDiffDetails>,
},
SpeedChanges {
changes: Vec<BakeryCommands>,
},
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<BakeryC
#[cfg(test)]
mod tests {
use super::{SiteDiffResult, diff_sites};
use super::{SiteDiffResult, StructuralSiteDiffDetails, diff_sites};
use crate::BakeryCommands;
use lqos_bus::TcHandle;
use std::collections::HashMap;
@@ -345,10 +362,12 @@ mod tests {
add_site(30, 0x10030, 0x20030, 0x31, 1.0, 1.0, 10.0, 10.0),
];
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
let SiteDiffResult::RebuildRequired { summary, details } = diff_sites(&batch, &old_sites)
else {
panic!("expected rebuild-required site diff");
};
assert!(details.is_none());
assert!(summary.contains("site_count_mismatch"));
assert!(summary.contains("added_count=2"));
assert!(summary.contains("removed_count=1"));
@@ -376,10 +395,12 @@ mod tests {
));
}
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
let SiteDiffResult::RebuildRequired { summary, details } = diff_sites(&batch, &old_sites)
else {
panic!("expected rebuild-required site diff");
};
assert!(details.is_none());
assert!(summary.contains("site_count_mismatch"));
assert!(summary.contains("added_count=10"));
assert!(summary.contains("removed_count=1"));
@@ -394,10 +415,12 @@ mod tests {
let old_sites = HashMap::from([(10, old_site)]);
let batch = vec![new_site];
let SiteDiffResult::RebuildRequired { summary } = diff_sites(&batch, &old_sites) else {
let SiteDiffResult::RebuildRequired { summary, details } = diff_sites(&batch, &old_sites)
else {
panic!("expected rebuild-required site diff");
};
assert_eq!(details, Some(StructuralSiteDiffDetails { site_hash: 10 }));
assert!(summary.contains("site_hash=10"));
assert!(summary.contains("parent=0x1:0x10→0x1:0x20"));
assert!(summary.contains("up_parent=0x2:0x10→0x2:0x20"));
+126 -2
View File
@@ -41,7 +41,9 @@ use crate::commands::{
ExecutionMode, RuntimeNodeOperationAction, RuntimeNodeOperationFailureReason,
RuntimeNodeOperationSnapshot, RuntimeNodeOperationStatus,
};
use crate::diff::{CircuitDiffResult, SiteDiffResult, diff_circuits, diff_sites};
use crate::diff::{
CircuitDiffResult, SiteDiffResult, StructuralSiteDiffDetails, diff_circuits, diff_sites,
};
use crate::qdisc_handles::QdiscHandleState;
use crate::queue_math::{SqmKind, effective_sqm_kind, format_rate_for_tc_f32, quantum, r2q};
use crate::utils::{
@@ -4184,7 +4186,16 @@ fn handle_commit_batch(
}
let structural_site_change_mode = diff_sites(&raw_batch, &baseline_sites);
if let SiteDiffResult::RebuildRequired { summary } = &structural_site_change_mode {
if let SiteDiffResult::RebuildRequired { summary, details } = &structural_site_change_mode {
if let Some(details) = details {
log_structural_site_diff_baseline_origin(
*details,
sites,
&baseline_sites,
&raw_batch,
virtualized_sites,
);
}
let (new_batch, mapped_limit_stats) = filter_batch_by_mapped_circuit_limit(
raw_batch.clone(),
&baseline_circuits,
@@ -7530,6 +7541,119 @@ fn reconstruct_structural_baseline_state(
(baseline_sites, baseline_circuits)
}
fn site_command_structure_summary(command: &BakeryCommands) -> Option<String> {
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<BakeryCommands>],
site_hash: i64,
) -> Option<&Arc<BakeryCommands>> {
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<i64, Arc<BakeryCommands>>,
baseline_sites: &HashMap<i64, Arc<BakeryCommands>>,
raw_batch: &[Arc<BakeryCommands>],
virtualized_sites: &HashMap<i64, VirtualizedSiteState>,
) {
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<i64, Migration>,
@@ -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
@@ -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<String>,
pub ltu_model_ids: Vec<String>,
pub active_status_ids: Vec<String>,
#[serde(default)]
pub recurring_service_rates: Vec<SonarRecurringServiceRate>,
#[serde(default)]
pub recurring_excluded_service_names: Vec<String>,
}
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![],
}
}
}
+30
View File
@@ -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<Vec<String>> {
Ok(key)
}
#[pyfunction]
fn sonar_recurring_service_rates() -> PyResult<Vec<(bool, String, f32, f32)>> {
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<Vec<String>> {
let config = lqos_config::load_config().unwrap();
Ok(config
.sonar_integration
.recurring_excluded_service_names
.clone())
}
#[pyfunction]
fn influx_db_enabled() -> PyResult<bool> {
let config = lqos_config::load_config().unwrap();
@@ -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)
);
}
@@ -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 = "";
@@ -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;
}
@@ -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 = `
<td><input type="checkbox" class="form-check-input recurring-enabled"></td>
<td><input type="text" class="form-control form-control-sm recurring-service-name"></td>
<td><input type="number" min="0" step="0.01" class="form-control form-control-sm recurring-download"></td>
<td><input type="number" min="0" step="0.01" class="form-control form-control-sm recurring-upload"></td>
<td class="text-end"><button type="button" class="btn btn-outline-danger btn-sm recurring-remove"><i class="fa fa-trash"></i></button></td>
`;
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', () => {
@@ -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));
@@ -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);
@@ -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);
@@ -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 += `<div class="exec-heat-cell empty" title="No data" aria-label="Block ${i + 1}: no data"></div>`;
cells += `<div class="exec-heat-cell empty" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-container="body" data-bs-trigger="hover focus" data-bs-html="true" tabindex="0" title="${escapeAttr(`<div class=&quot;fw-semibold&quot;>15-minute block ${i + 1}</div><div>No data</div><div>This block has no QoO/QoQ totals yet.</div>`)}" aria-label="${escapeAttr(`15-minute block ${i + 1}. No data. This block has no QoO/QoQ totals yet.`)}"></div>`;
continue;
}
const title = [
`Block ${i + 1}`,
`UL Total: ${fmt(ulTotal)}`,
`DL Total: ${fmt(dlTotal)}`,
].join(" • ");
const title = `
<div class="fw-semibold">15-minute block ${i + 1}</div>
<div>Upload score: ${fmt(ulTotal)}</div>
<div>Download score: ${fmt(dlTotal)}</div>
<div>Top is upload, bottom is download. Higher scores are better.</div>
`;
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 += `
<div class="exec-heat-cell split" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}">
<div class="exec-heat-cell split" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-container="body" data-bs-trigger="hover focus" data-bs-html="true" tabindex="0" title="${escapeAttr(title)}" aria-label="${escapeAttr(aria)}">
<div class="exec-split-grid">
${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 = `
<div class="card shadow-sm border-0">
<div class="card-body py-3">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
@@ -321,7 +328,12 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase {
</div>
</div>
`;
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
? `<a class="text-decoration-none text-secondary" href="${this.config.link}"><i class="fas ${this.config.icon} me-2 text-primary"></i>${titleLabel}${linkIcon}</a>`
: `<i class="fas ${this.config.icon} me-2 text-primary"></i>${titleLabel}`;
disposeTooltipsWithin(activeTarget);
activeTarget.innerHTML = `
const html = `
<div class="card shadow-sm border-0 h-100">
<div class="card-body py-3">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
@@ -422,7 +438,12 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase {
</div>
</div>
`;
enableTooltipsWithin(activeTarget);
replaceHeatmapHtml(activeTarget, html, (nextHtml) => {
disposeTooltipsWithin(activeTarget);
activeTarget.innerHTML = nextHtml;
activeTarget.__lqosLastHeatmapHtml = nextHtml;
enableTooltipsWithin(activeTarget);
});
}
summaryRows(summary) {
@@ -130,6 +130,59 @@ function escapeHeatAttr(text) {
.replace(/>/g, "&gt;");
}
function tooltipHtmlFromLines(lines) {
return (Array.isArray(lines) ? lines : [])
.filter((line) => !!String(line ?? "").trim())
.map((line, idx) => `<div${idx === 0 ? ` class="fw-semibold"` : ""}>${escapeHeatAttr(line)}</div>`)
.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 += `<div class="exec-heat-cell empty" title="No data" aria-label="Block ${i + 1}: no data"></div>`;
cells += `<div class="exec-heat-cell empty" ${tooltipAttrsFromLines(noDataTooltipLines(i + 1, "This block has no metric data yet."))}></div>`;
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 += `<div class="exec-heat-cell" style="background:${color}" title="${escapeHeatAttr(label)}" aria-label="${escapeHeatAttr(label)}"></div>`;
const tooltipLines = scalarTooltipLines(
i + 1,
"Value",
title,
"This cell shows the metric value for this 15-minute block.",
detail ? `Context: ${detail}` : "",
);
cells += `<div class="exec-heat-cell" style="background:${color}" ${tooltipAttrsFromLines(tooltipLines)}></div>`;
}
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 += `<div class="exec-heat-cell empty" title="No data" aria-label="Block ${i + 1}: no RTT data"></div>`;
cells += `<div class="exec-heat-cell empty" ${tooltipAttrsFromLines(noDataTooltipLines(i + 1, "This block has no RTT samples yet."))}></div>`;
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 += `
<div class="exec-heat-cell quad" title="${escapeHeatAttr(title)}" aria-label="${escapeHeatAttr(title)}">
<div class="exec-heat-cell quad" ${tooltipAttrsFromLines(tooltipLines)}>
<div class="exec-quad-grid">
${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 += `<div class="exec-heat-cell empty" title="No data" aria-label="Block ${i + 1}: no data"></div>`;
cells += `<div class="exec-heat-cell empty" ${tooltipAttrsFromLines(noDataTooltipLines(i + 1, meaning))}></div>`;
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 += `
<div class="exec-heat-cell split" title="${escapeHeatAttr(title)}" aria-label="${escapeHeatAttr(title)}">
<div class="exec-heat-cell split" ${tooltipAttrsFromLines(tooltipLines)}>
<div class="exec-split-grid">
${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
<div class="fw-semibold text-truncate${redactClass}">${labelMarkup}</div>
${badge ? `<span class="badge bg-light text-secondary border">${badge}</span>` : ""}
</div>
<div class="exec-heat-cells" role="img" aria-label="${escapeHeatAttr(`${label} heatmap history`)}">${heatmapRowSplit(topValues, bottomValues, colorFn, formatValue)}</div>
<div class="exec-heat-cells" role="img" aria-label="${escapeHeatAttr(`${label} heatmap history`)}">${heatmapRowSplit(topValues, bottomValues, colorFn, formatValue, options)}</div>
<div class="text-muted small text-end exec-latest split">${formattedLatest}</div>
</div>
`;
@@ -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.",
});
}
@@ -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);
}
@@ -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);
@@ -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);
@@ -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 = "";
@@ -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 = `
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
@@ -211,7 +213,13 @@ function renderHeatmapTable(targetId, metricKey) {
</div>
</div>
`;
attachPaginationHandlers(target, state);
replaceHeatmapHtml(target, html, (nextHtml) => {
disposeTooltipsWithin(target);
target.innerHTML = nextHtml;
target.__lqosLastHeatmapHtml = nextHtml;
attachPaginationHandlers(target, state);
enableTooltipsWithin(target);
});
};
window.addEventListener("colorBlindModeChanged", () => {
@@ -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()) {
@@ -340,6 +340,10 @@ function displayParentName(name, parentType) {
: name;
}
function redactableHtml(text) {
return `<span class="redactable">${escapeHtml(text ?? "")}</span>`;
}
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) => `<div class="site-map-list-item">${escapeHtml(displayNodeName(node.name, node.type))}</div>`)
.map((node) => `<div class="site-map-list-item">${redactableHtml(displayNodeName(node.name, node.type))}</div>`)
.join("");
const more = attachedAps.length > 6
? `<div class="site-map-empty">+${attachedAps.length - 6} more APs attached to this site</div>`
@@ -1984,7 +1993,7 @@ class SiteMapPage {
renderUnmappedGroup(label, nodes) {
const items = nodes
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => `<div class="site-map-list-item">${escapeHtml(displayNodeName(node.name, node.type))}${node.parentName ? `<div class="text-muted small">${escapeHtml(displayParentName(node.parentName, node.parentType))}</div>` : ""}</div>`)
.map((node) => `<div class="site-map-list-item">${redactableHtml(displayNodeName(node.name, node.type))}${node.parentName ? `<div class="text-muted small">${redactableHtml(displayParentName(node.parentName, node.parentType))}</div>` : ""}</div>`)
.join("");
return `<div class="site-map-list-group"><h6>${escapeHtml(label)}</h6>${items}</div>`;
}
@@ -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<f32>,
pub qoo_up: Option<f32>,
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<CircuitFlowSnapshotRow> {
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<u64> {
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<f32> {
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<u64>,
rtt_up_nanos: Vec<u64>,
qoo_down: Vec<f32>,
qoo_up: Vec<f32>,
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<u32, AsnBucket> = 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<CircuitTopAsnRow> = 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<CircuitFlowSankeyRow> {
})
.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));
}
}
@@ -117,7 +117,10 @@ fn classify_signup_check_response(response: SignupCheckResponse) -> Result<Signu
}
}
async fn apply_insight_license(license_key: String, restart_lqos_api: bool) -> 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);
@@ -53,6 +53,34 @@
<div class="form-text">Comma-separated list of active status IDs</div>
</div>
<div class="mb-3">
<label for="recurringExcludedServiceNames" class="form-label">Recurring Service Exclusions</label>
<input type="text" class="form-control" id="recurringExcludedServiceNames">
<div class="form-text">Comma-separated recurring service names that should never be treated as internet plans.</div>
</div>
<div class="mb-3">
<label class="form-label">Recurring Service Rate Overrides</label>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th scope="col">Enabled</th>
<th scope="col">Exact Service Name</th>
<th scope="col">Download Mbps</th>
<th scope="col">Upload Mbps</th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="recurringServiceRatesBody"></tbody>
</table>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="addRecurringRateRow">
<i class="fa fa-plus"></i> Add Recurring Rate
</button>
<div class="form-text">Used only when an active account has no usable DATA service. Match names exactly and whitelist only internet plans.</div>
</div>
</div>
</section>
+2 -1
View File
@@ -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;
@@ -35,7 +35,10 @@ fn qoo_score_for_circuit(circuit: &str) -> Option<f32> {
})
}
fn weighted_directional_rtt_p50_nanos(devices: &[Circuit], direction: fn(&Circuit) -> (u64, Option<u64>)) -> Option<u64> {
fn weighted_directional_rtt_p50_nanos(
devices: &[Circuit],
direction: fn(&Circuit) -> (u64, Option<u64>),
) -> Option<u64> {
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 {
@@ -383,6 +383,112 @@ pub(crate) async fn find_troublesome_sites(data: &UispData) -> anyhow::Result<Tr
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::strategies::common::UispData;
use crate::uisp_types::{UispDevice, UispSite, UispSiteType};
use std::collections::HashSet;
#[test]
fn ap_site_network_json_keeps_empty_shells_when_client_has_no_addresses() {
let config = Config::default();
let uisp_data = UispData {
sites_raw: vec![],
devices_raw: vec![],
data_links_raw: vec![],
sites: vec![
UispSite {
id: "tower-site".to_string(),
name: "Tower 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,
..Default::default()
},
],
devices: vec![
UispDevice {
id: "ap-1".to_string(),
name: "AP 1".to_string(),
mac: "".to_string(),
role: None,
wireless_mode: None,
site_id: "tower-site".to_string(),
download: 500,
upload: 500,
ipv4: HashSet::from(["192.0.2.1/32".to_string()]),
ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
},
UispDevice {
id: "cpe-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 root = Layer {
id: GraphMapping::Root,
children: vec![Layer {
id: GraphMapping::SiteByName("Tower Site".to_string()),
children: vec![Layer {
id: GraphMapping::AccessPointByName("AP 1".to_string()),
children: vec![Layer {
id: GraphMapping::ClientById("client-site".to_string()),
children: vec![],
}],
}],
}],
};
let mut shaped_devices = Vec::new();
let mut ethernet_advisories = Vec::new();
let network_json = root.walk_children(
None,
&uisp_data,
&mut shaped_devices,
&mut ethernet_advisories,
&config,
);
assert!(shaped_devices.is_empty());
let tower_site = network_json
.get("Tower Site")
.and_then(|v| v.as_object())
.unwrap();
let site_children = tower_site
.get("children")
.and_then(|v| v.as_object())
.unwrap();
assert!(site_children.contains_key("AP 1"));
let ap = site_children
.get("AP 1")
.and_then(|v| v.as_object())
.unwrap();
let ap_children = ap.get("children").and_then(|v| v.as_object()).unwrap();
assert!(ap_children.is_empty());
}
}
fn find_clients_with_multiple_entry_points(data: &UispData) -> anyhow::Result<HashSet<String>> {
let mut result = HashSet::new();
for client in data.find_client_sites() {
@@ -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());
}
}
@@ -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")
);
}
}
+35
View File
@@ -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
@@ -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()
+418
View File
@@ -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()