From 6999892afc7d23ea963a804f90b0b72ed81a87b7 Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Mon, 20 Jul 2026 11:03:32 +0530 Subject: [PATCH 1/7] lf_webpage.py: Added logging for http test VERIFIED CLI: python3 lf_webpage.py --mgr 192.168.207.75 --upstream_port eth1 --duration 10m --bands 5G --client_type Real --file_size 2MB Signed-off-by: haricharan-candela --- py-scripts/lf_webpage.py | 83 ++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/py-scripts/lf_webpage.py b/py-scripts/lf_webpage.py index f6acb2c86..80f6c29c2 100755 --- a/py-scripts/lf_webpage.py +++ b/py-scripts/lf_webpage.py @@ -511,7 +511,7 @@ def api_get(self, endp: str): def filter_iOS_devices(self, device_list): modified_device_list = device_list - if type(device_list) is str: + if isinstance(device_list, str): modified_device_list = device_list.split(',') filtered_list = [] for device in modified_device_list: @@ -531,7 +531,7 @@ def filter_iOS_devices(self, device_list): logger.info("%s is an iOS device. Currently, we do not support iOS devices.", device) else: filtered_list.append(device) - if type(device_list) is str: + if isinstance(device_list, str): filtered_list = ','.join(filtered_list) self.device_list = filtered_list return filtered_list @@ -603,6 +603,27 @@ def precleanup(self): time.sleep(1) print("precleanup done") + def get_upstream_ip(self): + """Gives the upstream ip""" + data = self.local_realm.json_get("ports/list?fields=IP") + eid = self.local_realm.name_to_eid(self.upstream) + + port_name = f"{eid[0]}.{eid[1]}.{eid[2]}" + + for interface in data.get("interfaces", []): + if port_name in interface: + ip = interface[port_name].get("ip") + + if not ip: + logger.error("No IP found for upstream port %s", port_name) + return None + + logger.info("Upstream IP: %s", ip) + return ip + + logger.error("Unable to locate upstream port %s", port_name) + return None + def build(self): # enable http on ethernet self.port_util.set_http(port_name=self.local_realm.name_to_eid(self.upstream)[2], @@ -629,16 +650,9 @@ def build(self): # building layer4 self.http_profile.direction = 'dl' self.http_profile.dest = '/dev/null' - data = self.local_realm.json_get("ports/list?fields=IP") - - # getting eth ip - eid = self.local_realm.name_to_eid(self.upstream) - for i in data["interfaces"]: - for j in i: - if "{shelf}.{resource}.{port}".format(shelf=eid[0], resource=eid[1], port=eid[2]) == j: - ip_upstream = i["{shelf}.{resource}.{port}".format( - shelf=eid[0], resource=eid[1], port=eid[2])]['ip'] - + ip_upstream = self.get_upstream_ip() + if ip_upstream is None: + raise RuntimeError("Failed to determine upstream IP") # create http profile if self.get_url_from_file: # enabling the GET-URL-FROM-FILE flag if its ture self.http_profile.create(ports=self.station_profile.station_names, sleep_time=.5, @@ -655,20 +669,18 @@ def build(self): else: if self.client_type == "Real": self.http_profile.direction = 'dl' - data = self.local_realm.json_get("ports/list?fields=IP") - - # getting eth ip - eid = self.local_realm.name_to_eid(self.upstream) - for i in data["interfaces"]: - for j in i: - if "{shelf}.{resource}.{port}".format(shelf=eid[0], resource=eid[1], port=eid[2]) == j: - ip_upstream = i["{shelf}.{resource}.{port}".format( - shelf=eid[0], resource=eid[1], port=eid[2])]['ip'] + ip_upstream = self.get_upstream_ip() + if ip_upstream is None: + raise RuntimeError("Failed to determine upstream IP") self.http_profile.create(ports=self.port_list, sleep_time=.5, suppress_related_commands_=None, http=True, interop=True, user=self.lf_username, passwd=self.lf_password, http_ip=ip_upstream + "/webpage.html", proxy_auth_type=0x200, timeout=1000, windows_list=self.windows_ports) + if not self.http_profile.created_cx: + logger.error("No Layer4 CXs created for ports %s", self.station_profile.station_names) + raise RuntimeError("CX creation failed") + logger.info("Created %d CX(s)", len(self.http_profile.created_cx)) print("Test Build done") @@ -722,9 +734,16 @@ def get_layer4_data(self): cx_list = list(self.http_profile.created_cx.keys()) try: url_str = 'layer4/{}/list?fields=uc-avg,uc-max,uc-min,total-urls,rx rate (1m),bytes-rd,total-err'.format(','.join(cx_list)) - l4_data = self.local_realm.json_get(url_str)['endpoint'] - except Exception: - logger.error("l4 DATA not found") + response = self.local_realm.json_get(url_str) + if not response: + logger.error("Layer4 response is empty") + return {} + l4_data = response.get("endpoint") + if l4_data is None: + logger.error("Layer4 endpoint data missing") + return {} + except Exception as e: + logger.error("l4 DATA not found, {%s}", e) exit(1) l4_dict = { 'uc_avg_data': [], @@ -752,6 +771,7 @@ def get_layer4_data(self): l4_dict['total_err'].append(value['total-err']) cx_found = True if not cx_found: + logger.error("Layer4 endpoint missing for CX %s. Using previous values.", cx) self.failed_cx.append(cx) l4_dict['uc_avg_data'].append(0 if not self.tracking_map else self.tracking_map['uc_avg_data'][idx]) l4_dict['uc_max_data'].append(0 if not self.tracking_map else self.tracking_map['uc_max_data'][idx]) @@ -923,6 +943,7 @@ def monitor_for_runtime_csv(self, duration): self.data["rx rate (1m)"] = rx_rate self.data["total_err"] = total_err else: + logger.error("Runtime data mismatch: Devices=%d URLs=%d RX=%d Bytes=%d", len(self.devices_list), len(url_times), len(rx_rate), len(bytes_rd)) self.data["status"] = ["RUNNING"] * len(self.devices_list) self.data["url_data"] = [0] * len(self.devices_list) self.data["uc_avg"] = [0] * len(self.devices_list) @@ -1081,15 +1102,10 @@ def file_create(self, ssh_port): stdin, stdout, stderr = ssh.exec_command(str(cmd1)) output = stdout.readlines() time.sleep(10) - cmd2 = "sudo fallocate -l " + self.file_size + " /usr/local/lanforge/nginx/html/webpage.html" - stdin, stdout, stderr = ssh.exec_command(str(cmd2)) - print("File creation done", self.file_size) - output = stdout.readlines() - else: - cmd2 = "sudo fallocate -l " + self.file_size + " /usr/local/lanforge/nginx/html/webpage.html" - stdin, stdout, stderr = ssh.exec_command(str(cmd2)) - print("File creation done", self.file_size) - output = stdout.readlines() + cmd2 = "sudo fallocate -l " + self.file_size + " /usr/local/lanforge/nginx/html/webpage.html" + stdin, stdout, stderr = ssh.exec_command(str(cmd2)) + print("File creation done", self.file_size) + output = stdout.readlines() ssh.close() time.sleep(1) return output @@ -3323,6 +3339,7 @@ def main(): # FOR WEBGUI, filling csv at the end to get the last terminal logs if args.dowebgui: http.copy_reports_to_home_dir() + logger.info("successfully ran the http test") if __name__ == '__main__': From dcd40afc3a321802cdae79c3715a84daf870d113 Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Thu, 23 Jul 2026 18:02:59 +0530 Subject: [PATCH 2/7] lf_webpage.py: Added retries on monitoring the cxs data VERIFIED CLI: python3 lf_webpage.py --mgr 192.168.207.78 --upstream_port eth1 --duration 1m --bands 5G --client_type Real --file_size 2MB Signed-off-by: haricharan-candela --- py-scripts/lf_webpage.py | 219 +++++++++++++++++++++++++++++++-------- 1 file changed, 174 insertions(+), 45 deletions(-) diff --git a/py-scripts/lf_webpage.py b/py-scripts/lf_webpage.py index 80f6c29c2..d3889cf43 100755 --- a/py-scripts/lf_webpage.py +++ b/py-scripts/lf_webpage.py @@ -122,6 +122,7 @@ from typing import List, Optional import csv from lf_base_robo import RobotClass +from lf_interop_utils import resolve_layer4_fields, layer4_fields_query sys.path.append(os.path.join(os.path.abspath(__file__ + "../../../"))) @@ -141,6 +142,10 @@ logger = logging.getLogger(__name__) +# Logical layer4 fields read from a CX record by get_layer4_data()/get_all_l4_data(), resolved +# to this server's actual column names via lf_interop_utils.resolve_layer4_fields(). +L4_FIELD_KEYS = ('uc_avg', 'uc_max', 'uc_min', 'total_urls', 'rx_rate_1m', 'bytes_rd', 'total_err', 'status') + iot_scripts_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../local/interop-webGUI/IoT/scripts/")) if os.path.exists(iot_scripts_path): sys.path.insert(0, iot_scripts_path) @@ -256,6 +261,13 @@ def __init__(self, lfclient_host, lfclient_port, upstream, num_sta, security, ss self.cycles = cycles self.bssids = bssids.split(',') if bssids else [] self.duration_to_skip = duration_to_skip + self.missing_cx_logged = set() + self.missing_device_logged = set() + self.cx_status_log = {} + self.device_issue_log = [] + self.monitor_start_time = None + self.actual_monitor_duration = 0 + self.l4_fields = None # The 'phantom_check' will be handled within the 'get_real_client_list' function def get_real_client_list(self): @@ -727,21 +739,21 @@ def update_stop_status_robot(self): def get_layer4_data(self): """ Fetch Layer 4 stats (uc-avg, uc-min, uc-max, urls, rx rate, bytes read, errors) - for all connections in self.cx_list. + for all connections currently created on the http profile. Returns: dict: mapping of metric names to lists of values, one per CX. """ cx_list = list(self.http_profile.created_cx.keys()) + if self.l4_fields is None and cx_list: + self.l4_fields = resolve_layer4_fields(self.local_realm, cx_list[0], L4_FIELD_KEYS) + fields = self.l4_fields or resolve_layer4_fields(self.local_realm, None, L4_FIELD_KEYS) try: - url_str = 'layer4/{}/list?fields=uc-avg,uc-max,uc-min,total-urls,rx rate (1m),bytes-rd,total-err'.format(','.join(cx_list)) + url_str = 'layer4/{}/list?fields={}'.format(','.join(cx_list), layer4_fields_query(fields, L4_FIELD_KEYS)) response = self.local_realm.json_get(url_str) - if not response: - logger.error("Layer4 response is empty") - return {} - l4_data = response.get("endpoint") - if l4_data is None: + endpoint_data = response.get("endpoint") if response else None + if endpoint_data is None: logger.error("Layer4 endpoint data missing") - return {} + endpoint_data = [] except Exception as e: logger.error("l4 DATA not found, {%s}", e) exit(1) @@ -752,27 +764,36 @@ def get_layer4_data(self): 'url_times': [], 'rx_rate': [], 'bytes_rd': [], - 'total_err': [] + 'total_err': [], + 'status': [] } - if not isinstance(l4_data, list): - l4_data = [{l4_data['name']: l4_data}] + if not isinstance(endpoint_data, list): + endpoint_data = [{endpoint_data['name']: endpoint_data}] idx = 0 for cx in cx_list: cx_found = False - for i in l4_data: + for i in endpoint_data: for cx_name, value in i.items(): if cx == cx_name: - l4_dict['uc_avg_data'].append(value['uc-avg']) - l4_dict['uc_max_data'].append(value['uc-max']) - l4_dict['uc_min_data'].append(value['uc-min']) - l4_dict['url_times'].append(value['total-urls']) - l4_dict['rx_rate'].append(value['rx rate (1m)']) - l4_dict['bytes_rd'].append(value['bytes-rd']) - l4_dict['total_err'].append(value['total-err']) + l4_dict['uc_avg_data'].append(value[fields['uc_avg']]) + l4_dict['uc_max_data'].append(value[fields['uc_max']]) + l4_dict['uc_min_data'].append(value[fields['uc_min']]) + l4_dict['url_times'].append(value[fields['total_urls']]) + l4_dict['rx_rate'].append(value[fields['rx_rate_1m']]) + l4_dict['bytes_rd'].append(value[fields['bytes_rd']]) + l4_dict['total_err'].append(value[fields['total_err']]) + l4_dict['status'].append(value.get(fields['status'], '')) + self.track_cx_status(cx, value.get(fields['status'], '')) cx_found = True if not cx_found: - logger.error("Layer4 endpoint missing for CX %s. Using previous values.", cx) - self.failed_cx.append(cx) + if cx not in self.missing_cx_logged: + logger.warning( + "CX '%s' is missing from the monitoring data, the device may have " + "disconnected or its connection was not created. Continuing the test " + "with the remaining devices.", cx) + self.missing_cx_logged.add(cx) + self.failed_cx.append(cx) + self.record_device_issue(cx, "CX missing from monitoring data") l4_dict['uc_avg_data'].append(0 if not self.tracking_map else self.tracking_map['uc_avg_data'][idx]) l4_dict['uc_max_data'].append(0 if not self.tracking_map else self.tracking_map['uc_max_data'][idx]) l4_dict['uc_min_data'].append(0 if not self.tracking_map else self.tracking_map['uc_min_data'][idx]) @@ -780,11 +801,83 @@ def get_layer4_data(self): l4_dict['rx_rate'].append(0 if not self.tracking_map else self.tracking_map['rx_rate'][idx]) l4_dict['bytes_rd'].append(0 if not self.tracking_map else self.tracking_map['bytes_rd'][idx]) l4_dict['total_err'].append(0 if not self.tracking_map else self.tracking_map['total_err'][idx]) + l4_dict['status'].append('Stopped') + # Don't route through track_cx_status here: the "CX missing" warning/issue above + # already records this event, so this just keeps cx_status_log's baseline in + # sync without writing a second, redundant issue-log entry. + if self.monitoring_elapsed_seconds() >= 10: + self.cx_status_log[cx] = 'Stopped' + elif cx in self.missing_cx_logged: + logger.info("CX '%s' data is available again.", cx) + self.missing_cx_logged.discard(cx) idx += 1 self.tracking_map = l4_dict.copy() return l4_dict + def record_device_issue(self, device, issue): + self.device_issue_log.append({ + "Time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "Device": device, + "Issue": issue, + }) + + def monitoring_elapsed_seconds(self): + if not self.monitor_start_time: + return 0 + return (datetime.now() - self.monitor_start_time).total_seconds() + + def track_cx_status(self, cx, status): + # Ignore CX status for the first 10s of monitoring: CXs are still settling into "Run" + # right after the test starts, and treating that startup ramp-up as a real status + # change/recovery would be a false positive. + if not status or self.monitoring_elapsed_seconds() < 10: + return + previous = self.cx_status_log.get(cx) + if previous is not None and previous != status: + if status.lower() != 'run': + logger.warning("CX '%s' status changed: %s -> %s", cx, previous, status) + self.record_device_issue(cx, "Status changed: {} -> {}".format(previous, status)) + elif previous.lower() != 'run': + logger.info("CX '%s' recovered: %s -> %s", cx, previous, status) + self.record_device_issue(cx, "Recovered: {} -> {}".format(previous, status)) + self.cx_status_log[cx] = status + + def format_monitoring_duration(self): + total_seconds = int(self.actual_monitor_duration) + minutes, seconds = divmod(total_seconds, 60) + return "{}m {}s".format(minutes, seconds) + + def wait_for_any_cx_recovery(self, timeout=40, poll_interval=5): + """ + Polls layer4 data (via get_layer4_data) while every created CX is missing, giving + devices a chance to reappear before the caller gives up on this monitoring iteration. + Also honors a user-initiated stop from the webgui during the wait, so a stop request + isn't delayed by the full retry window. + + Returns 'recovered' as soon as at least one CX responds again, 'stopped' if the user + stops the test during the wait, or 'timeout' if `timeout` seconds elapse with every CX + still missing. + """ + wait_start = datetime.now() + created_cx_count = len(self.http_profile.created_cx) + while (datetime.now() - wait_start).total_seconds() < timeout: + time.sleep(poll_interval) + if self.dowebgui == "True": + with open(self.result_dir + "/../../Running_instances/{}_{}_running.json".format( + self.host, self.test_name), 'r') as file: + data = json.load(file) + if data["status"] != "Running": + logger.info("Test is stopped by the user during the device-recovery wait.") + return 'stopped' + self.get_layer4_data() + elapsed = (datetime.now() - wait_start).total_seconds() + if len(self.missing_cx_logged) < created_cx_count: + logger.info("Device(s) responded again after %.0fs, resuming.", elapsed) + return 'recovered' + logger.warning("Still no devices responding after %.0fs, retrying...", elapsed) + return 'timeout' + def aggregate_rx_bytes(self, rx_rate, bytes_rd): """ Compute average RX rate and update max bytes read. @@ -822,6 +915,7 @@ def aggregate_rx_bytes(self, rx_rate, bytes_rd): def monitor_for_runtime_csv(self, duration): + self.monitor_start_time = datetime.now() time_now = datetime.now() starttime = time_now.strftime("%d/%m %I:%M:%S %p") # duration = self.traffic_duration @@ -896,6 +990,29 @@ def monitor_for_runtime_csv(self, duration): # total_url_data = self.json_get("layer4/list?fields=total-urls") # bytes_rd = self.json_get("layer4/list?fields=bytes-rd") l4_dict = self.get_layer4_data() + + # If every CX has stopped responding, retry for up to 40 seconds before giving up + # on this monitor loop. This does not fail the test: the data below is still + # assembled (get_layer4_data() already fills in zero/previous-value fallbacks for + # every CX) so self.data keeps all its expected keys, and the loop only ends + # *after* that data is saved, same as the existing webgui-stop check below. + end_monitor_loop = False + created_cx_count = len(self.http_profile.created_cx) + if created_cx_count and len(self.missing_cx_logged) == created_cx_count: + logger.warning("All devices have stopped responding during monitoring, retrying " + "for up to 40 seconds before ending the monitor loop.") + recovery = self.wait_for_any_cx_recovery(timeout=40, poll_interval=5) + if recovery == 'stopped': + test_stopped_by_user = True + end_monitor_loop = True + elif recovery == 'timeout': + logger.error("No devices responded within 40 seconds during monitoring, " + "ending the monitor loop gracefully; the test will continue with " + "the data collected so far.") + end_monitor_loop = True + else: + l4_dict = self.get_layer4_data() + uc_avg_data = l4_dict['uc_avg_data'] uc_max_data = l4_dict['uc_max_data'] uc_min_data = l4_dict['uc_min_data'] @@ -981,6 +1098,8 @@ def monitor_for_runtime_csv(self, duration): if not self.do_bandsteering and self.robot_test: # Save FTP data values for the current coordinate when in robot test df1.to_csv(f"{self.current_coordinate}_http_datavalues.csv", index=False) + if end_monitor_loop: + break # No sleep is added here for band steering, as we need to capture data every second. # The per-second sleep interval is already handled in lf_base_robo. if not self.do_bandsteering: @@ -1013,6 +1132,7 @@ def monitor_for_runtime_csv(self, duration): df.to_csv("all_l4_data.csv", index=False) except Exception: logger.error("All l4 data not found") + self.actual_monitor_duration += (datetime.now() - self.monitor_start_time).total_seconds() return test_stopped_by_user def get_all_l4_data(self): @@ -1021,11 +1141,12 @@ def get_all_l4_data(self): Returns: dict: A dictionary mapping each Layer 4 field to a list of values in the order of CXs. """ + rx_rate_1m_field = (self.l4_fields or {}).get('rx_rate_1m', 'rx-rate-1m') fields = [ "name", "eid", "type", "status", "total-urls", "urls/s", "bytes-rd", "bytes-wr", "total-buffers", "total-rebuffers", "total-wait-time", "video-format-bitrate", "audio-format-bitrate", "frame-rate", "video-quality", "tx rate", "tx-rate-1m", - "rx rate", "rx rate (1m)", "fb-min", "fb-avg", "fb-max", "uc-min", "uc-avg", + "rx rate", rx_rate_1m_field, "fb-min", "fb-avg", "fb-max", "uc-min", "uc-avg", "uc-max", "dns-min", "dns-avg", "dns-max", "total-err", "bad-proto", "bad-url", "rslv-p", "rslv-h", "!conn", "timeout", "nf (4xx)", "http-r", "http-p", "http-t", "acc. denied", "ftp-host", "ftp-stor", "ftp-port", "write", "read", "redir", @@ -1036,7 +1157,7 @@ def get_all_l4_data(self): result = {field: [] for field in fields} - endpoint = data.get("endpoint", {}) + endpoint = data.get("endpoint", {}) if data else {} cx_list = self.http_profile.created_cx.keys() if isinstance(endpoint, dict): for field in fields: @@ -1591,9 +1712,13 @@ def generate_report(self, date, num_stations, duration, test_setup_info, dataset for coord, _ in self.robot_data.items(): # Build graphs and table for each coordinate self.build_graphs_and_table(coord, "", report, lis, bands) + if self.device_issue_log: + issues_df = pd.DataFrame(self.device_issue_log) + issues_df.to_csv(os.path.join(report_path_date_time, "clients_issue.csv"), index=False) report.build_footer() html_file = report.write_html() report.write_pdf() + logger.info("Monitoring Duration: %s", self.format_monitoring_duration()) return if self.do_bandsteering: self.get_bandsteering_stats(report) @@ -1870,11 +1995,15 @@ def generate_report(self, date, num_stations, duration, test_setup_info, dataset report.set_obj_html(_obj_title="Charging Timestamps", _obj="Robot did not went to charge during this test") report.build_objective() + if self.device_issue_log: + issues_df = pd.DataFrame(self.device_issue_log) + issues_df.to_csv(os.path.join(report_path_date_time, "clients_issue.csv"), index=False) report.build_footer() html_file = report.write_html() print("returned file {}".format(html_file)) print(html_file) report.write_pdf() + logger.info("Monitoring Duration: %s", self.format_monitoring_duration()) def copy_reports_to_home_dir(self): curr_path = self.result_dir @@ -2067,34 +2196,34 @@ def get_signal_and_link_speed_data(self): interfaces_dict.update(port) for sta in station_names: if sta in interfaces_dict: - if "dBm" in interfaces_dict[sta]['signal']: - signal_list.append(interfaces_dict[sta]['signal'].split(" ")[0]) + if sta in self.missing_device_logged: + logger.info("Signal data for device '%s' is available again.", sta) + self.missing_device_logged.discard(sta) + data = interfaces_dict[sta] + if "dBm" in data['signal']: + signal_list.append(data['signal'].split(" ")[0]) + else: + signal_list.append(data['signal']) + link_speed_list.append(data['tx-rate']) + rx_rate_list.append(data['rx-rate']) + bssid_list.append(data['ap']) + channel_value = str(data.get('channel', '')) + if channel_value in ('', '0', '-1'): + channel_list.append('NA') else: - signal_list.append(interfaces_dict[sta]['signal']) + channel_list.append(data['channel']) else: + if sta not in self.missing_device_logged: + logger.warning( + "Signal data for device '%s' is unavailable, it may have disconnected. " + "Continuing the test with the remaining devices.", sta) + self.missing_device_logged.add(sta) + self.record_device_issue(sta, "Signal data unavailable (device may have disconnected)") signal_list.append('-') - for sta in station_names: - if sta in interfaces_dict: - link_speed_list.append(interfaces_dict[sta]['tx-rate']) - else: link_speed_list.append('-') - for sta in station_names: - if sta in interfaces_dict: - rx_rate_list.append(interfaces_dict[sta]['rx-rate']) - else: rx_rate_list.append('-') - for sta in station_names: - if sta in interfaces_dict: - bssid_list.append(interfaces_dict[sta]['ap']) - else: bssid_list.append('-') - for sta in station_names: - if sta in interfaces_dict: - channel_value = str(interfaces_dict[sta].get('channel', '')) - if channel_value in ('', '0', '-1'): - channel_list.append('NA') - else: - channel_list.append(interfaces_dict[sta]['channel']) + channel_list.append('-') return signal_list, link_speed_list, rx_rate_list, bssid_list, channel_list def monitor_cx(self): From bde9522351e280fbad3644acc7baa73b9ff9ecba Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Thu, 23 Jul 2026 18:15:34 +0530 Subject: [PATCH 3/7] lf_interop_utils.py: Added interop utils file for l4 data fields VERIFIED CLI: python3 lf_webpage.py --mgr 192.168.207.78 --upstream_port eth1 --duration 1m --bands 5G --client_type Real --file_size 2MB Signed-off-by: haricharan-candela --- py-scripts/lf_interop_utils.py | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 py-scripts/lf_interop_utils.py diff --git a/py-scripts/lf_interop_utils.py b/py-scripts/lf_interop_utils.py new file mode 100644 index 000000000..39bcc0244 --- /dev/null +++ b/py-scripts/lf_interop_utils.py @@ -0,0 +1,75 @@ +""" +Shared helpers for interop test scripts (lf_webpage.py, lf_ftp.py, lf_interop_*.py, ...). + +LANforge server versions occasionally rename layer4/CX API field names (for example, the +RX-rate 1-minute-average column has been seen as both 'rx rate (1m)' and 'rx-rate-1m' across +different server builds). Requesting a name a given server doesn't recognize fails the whole +layer4 query and logs a "Columns do not include" error from the server. Scripts that need to +work across server versions should resolve field names through resolve_layer4_fields() below +instead of hardcoding one spelling, so a server-side rename doesn't turn into a broken request +in every script that touches that field. +""" +import logging + +logger = logging.getLogger(__name__) + +# Known alternate spellings LANforge servers have used for the same layer4 column, newest +# known name first. Every field read from a layer4 record should have an entry here (even a +# single-alias one) so a future rename only needs a new alias added in one place. +LAYER4_FIELD_ALIASES = { + 'uc_avg': ['uc-avg'], + 'uc_max': ['uc-max'], + 'uc_min': ['uc-min'], + 'total_urls': ['total-urls'], + 'rx_rate_1m': ['rx-rate-1m', 'rx rate (1m)'], + 'tx_rate_1m': ['tx-rate-1m', 'tx rate (1m)'], + 'bytes_rd': ['bytes-rd'], + 'total_err': ['total-err'], + 'status': ['status'], +} + + +def resolve_layer4_fields(local_realm, cx_name, field_keys, defaults=None): + """ + Determine which of each layer4 column's known alternate names this LANforge server + actually uses, without ever sending a request that names an unsupported column (which + would otherwise trigger a "Columns do not include" error from the server). Resolves every + requested field from a single probe request. + + Args: + local_realm: Realm instance used to make the probe request. + cx_name: name of an existing CX to probe; its columns reflect what the server supports. + field_keys: iterable of keys into LAYER4_FIELD_ALIASES to resolve. + defaults: optional {field_key: name} overrides for the fallback if detection fails; + otherwise the first known alias for that field is used. + + Returns: + dict: {field_key: resolved_api_field_name}, one entry per requested field_key. + """ + defaults = defaults or {} + candidates_by_key = {key: LAYER4_FIELD_ALIASES.get(key, [key]) for key in field_keys} + resolved = {key: defaults.get(key, candidates[0]) for key, candidates in candidates_by_key.items()} + if not cx_name: + return resolved + try: + # No 'fields' filter: the server returns every column it supports for this CX, so we + # can check which alias is present without risking an invalid-field-name error. + probe = local_realm.json_get('layer4/{}/list'.format(cx_name)) + endpoint = probe.get('endpoint') if probe else None + if isinstance(endpoint, list) and endpoint: + endpoint = list(endpoint[0].values())[0] + if isinstance(endpoint, dict): + for key, candidates in candidates_by_key.items(): + for candidate in candidates: + if candidate in endpoint: + resolved[key] = candidate + break + except Exception: + logger.warning("Could not probe layer4 columns, using default field names: %s", resolved) + return resolved + + +def layer4_fields_query(resolved_fields, field_keys): + """Build the comma-separated 'fields=' value for a layer4 list request, in order, from a + resolve_layer4_fields() result.""" + return ','.join(resolved_fields[key] for key in field_keys) From 2bc0cb6d9370e307ede5db3681c9e35534cfa15c Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Thu, 23 Jul 2026 18:15:49 +0530 Subject: [PATCH 4/7] lf_interop_utils.py: Updated the file to standalone and added respective docs strings VERIFIED CLI: python3 lf_interop_utils.py --mgr 192.168.200.165 --get_l4_fields Signed-off-by: haricharan-candela --- py-scripts/lf_interop_utils.py | 123 +++++++++++++++++++++++++-------- 1 file changed, 95 insertions(+), 28 deletions(-) diff --git a/py-scripts/lf_interop_utils.py b/py-scripts/lf_interop_utils.py index 39bcc0244..1ec1fbd83 100644 --- a/py-scripts/lf_interop_utils.py +++ b/py-scripts/lf_interop_utils.py @@ -1,20 +1,43 @@ +#!/usr/bin/env python3 """ -Shared helpers for interop test scripts (lf_webpage.py, lf_ftp.py, lf_interop_*.py, ...). - -LANforge server versions occasionally rename layer4/CX API field names (for example, the -RX-rate 1-minute-average column has been seen as both 'rx rate (1m)' and 'rx-rate-1m' across -different server builds). Requesting a name a given server doesn't recognize fails the whole -layer4 query and logs a "Columns do not include" error from the server. Scripts that need to -work across server versions should resolve field names through resolve_layer4_fields() below -instead of hardcoding one spelling, so a server-side rename doesn't turn into a broken request -in every script that touches that field. +NAME: lf_interop_utils.py + +PURPOSE: +lf_interop_utils.py provides shared helpers for interop test scripts (lf_webpage.py, lf_ftp.py, +lf_interop_*.py, ...). It resolves LANforge layer4 API field names that vary +across server versions -- for example, 'rx rate (1m)' and 'rx-rate-1m' on different LANforge server builds. Requesting a name a given +server doesn't recognize fails the whole layer4 query and it utils prevents it. + +Run standalone to inspect what a given LANforge server actually calls each of these fields, +without needing to start a full test first (an existing layer4 CX must already be running on +the server, e.g. started by another script, for there to be anything to probe). + +EXAMPLE-1: +Command Line Interface to detect a LANforge server's layer4 field names +python3 lf_interop_utils.py --mgr 192.168.200.165 --get_l4_fields + +EXAMPLE-2: +Command Line Interface to probe a specific CX instead of auto-picking the first one found +python3 lf_interop_utils.py --mgr 192.168.200.165 --get_l4_fields --cx_name wlan0_http30_l4 """ +import argparse +import importlib import logging +import os +import sys + +if sys.version_info[0] != 3: + print("This script requires Python 3") + exit(1) + +sys.path.append(os.path.join(os.path.abspath(__file__ + "../../../"))) +realm = importlib.import_module("py-json.realm") +Realm = realm.Realm logger = logging.getLogger(__name__) -# Known alternate spellings LANforge servers have used for the same layer4 column, newest -# known name first. Every field read from a layer4 record should have an entry here (even a +# Known alternate spellings LANforge servers have used for the same layer4 column, newest known +# name first. Every field read from a layer4 record should have an entry here (even a # single-alias one) so a future rename only needs a new alias added in one place. LAYER4_FIELD_ALIASES = { 'uc_avg': ['uc-avg'], @@ -22,7 +45,7 @@ 'uc_min': ['uc-min'], 'total_urls': ['total-urls'], 'rx_rate_1m': ['rx-rate-1m', 'rx rate (1m)'], - 'tx_rate_1m': ['tx-rate-1m', 'tx rate (1m)'], + 'tx_rate_1m': ['tx-rate-1m'], 'bytes_rd': ['bytes-rd'], 'total_err': ['total-err'], 'status': ['status'], @@ -30,22 +53,8 @@ def resolve_layer4_fields(local_realm, cx_name, field_keys, defaults=None): - """ - Determine which of each layer4 column's known alternate names this LANforge server - actually uses, without ever sending a request that names an unsupported column (which - would otherwise trigger a "Columns do not include" error from the server). Resolves every - requested field from a single probe request. - - Args: - local_realm: Realm instance used to make the probe request. - cx_name: name of an existing CX to probe; its columns reflect what the server supports. - field_keys: iterable of keys into LAYER4_FIELD_ALIASES to resolve. - defaults: optional {field_key: name} overrides for the fallback if detection fails; - otherwise the first known alias for that field is used. - - Returns: - dict: {field_key: resolved_api_field_name}, one entry per requested field_key. - """ + """Probe one CX to find which known alias each field_key uses on this server, returning + {field_key: resolved_name}.""" defaults = defaults or {} candidates_by_key = {key: LAYER4_FIELD_ALIASES.get(key, [key]) for key in field_keys} resolved = {key: defaults.get(key, candidates[0]) for key, candidates in candidates_by_key.items()} @@ -73,3 +82,61 @@ def layer4_fields_query(resolved_fields, field_keys): """Build the comma-separated 'fields=' value for a layer4 list request, in order, from a resolve_layer4_fields() result.""" return ','.join(resolved_fields[key] for key in field_keys) + + +def find_any_cx_name(local_realm): + """Return the name of any one layer4 CX currently on the server, or None if there aren't any.""" + try: + response = local_realm.json_get('layer4/list') + endpoint = response.get('endpoint') if response else None + except Exception: + return None + if isinstance(endpoint, dict): + return endpoint.get('name') + if isinstance(endpoint, list) and endpoint: + return list(endpoint[0].keys())[0] + return None + + +def main(): + parser = argparse.ArgumentParser( + prog='lf_interop_utils.py', + formatter_class=argparse.RawTextHelpFormatter, + description=__doc__) + parser.add_argument('--mgr', help='hostname for where LANforge GUI is running [default = localhost]', default='localhost') + parser.add_argument('--mgr_port', help='port LANforge GUI HTTP service is running on [default = 8080]', type=int, default=8080) + parser.add_argument('--cx_name', help='name of an existing layer4 CX to probe; if omitted, the first CX found on the server is used', default=None) + parser.add_argument('--get_l4_fields', help='resolve and print this LANforge server\'s layer4 field names (e.g. rx-rate-1m vs rx rate (1m))', action='store_true') + parser.add_argument('--help_summary', action='store_true', help='Show summary of what this script does') + args = parser.parse_args() + + help_summary = '''\ +lf_interop_utils.py provides shared helpers for interop test scripts, most notably resolving +LANforge layer4 API field names (e.g. 'rx rate (1m)' vs 'rx-rate-1m') that vary across server +versions. Run with --get_l4_fields to detect what a given LANforge server calls these fields. +''' + if args.help_summary: + print(help_summary) + exit(0) + + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + + if not args.get_l4_fields: + parser.print_help() + return + + local_realm = Realm(lfclient_host=args.mgr, lfclient_port=args.mgr_port) + cx_name = args.cx_name or find_any_cx_name(local_realm) + if not cx_name: + logger.error("No layer4 CXs found on %s:%s; start a test that creates one first, " + "or pass --cx_name explicitly.", args.mgr, args.mgr_port) + exit(1) + + fields = resolve_layer4_fields(local_realm, cx_name, LAYER4_FIELD_ALIASES.keys()) + print("Resolved layer4 field names on {}:{} (probed CX '{}'):".format(args.mgr, args.mgr_port, cx_name)) + for key, value in fields.items(): + print(" {:15s} -> {}".format(key, value)) + + +if __name__ == '__main__': + main() From 7b66357f745bec0b36f58a04083c4d0a4304b128 Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Tue, 28 Jul 2026 00:13:30 +0530 Subject: [PATCH 5/7] lf_webpage.py: updated the handling of cxs logging for http script Signed-off-by: haricharan-candela --- py-scripts/lf_webpage.py | 45 +++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/py-scripts/lf_webpage.py b/py-scripts/lf_webpage.py index d3889cf43..52e125623 100755 --- a/py-scripts/lf_webpage.py +++ b/py-scripts/lf_webpage.py @@ -268,6 +268,10 @@ def __init__(self, lfclient_host, lfclient_port, upstream, num_sta, security, ss self.monitor_start_time = None self.actual_monitor_duration = 0 self.l4_fields = None + # Set when a monitor_for_runtime_csv() recovery wait times out with every CX still + # unresponsive, so perform_robo() stops moving to further coordinates/rotations instead + # of continuing a robot test none of the devices can respond to. + self.all_devices_stopped = False # The 'phantom_check' will be handled within the 'get_real_client_list' function def get_real_client_list(self): @@ -787,10 +791,14 @@ def get_layer4_data(self): cx_found = True if not cx_found: if cx not in self.missing_cx_logged: + response_keys = [key for endpoint in endpoint_data + if isinstance(endpoint, dict) for key in endpoint] logger.warning( "CX '%s' is missing from the monitoring data, the device may have " "disconnected or its connection was not created. Continuing the test " - "with the remaining devices.", cx) + "with the remaining devices.\n" + "URL : %s\n" + "Response keys: %s", cx, url_str, response_keys) self.missing_cx_logged.add(cx) self.failed_cx.append(cx) self.record_device_issue(cx, "CX missing from monitoring data") @@ -914,8 +922,21 @@ def aggregate_rx_bytes(self, rx_rate, bytes_rd): return list(rx_rate), list(bytes_rd) def monitor_for_runtime_csv(self, duration): + if self.all_devices_stopped: + # A previous call already gave up waiting for devices to recover. Band steering + # invokes this function repeatedly as its own per-tick callback, so return + # immediately instead of re-running the 40s recovery wait on every tick. + return True - self.monitor_start_time = datetime.now() + if self.do_bandsteering: + # Band steering calls this function once per tick within one continuous session, + # so only start the CX-status grace period once for the whole session. + if self.monitor_start_time is None: + self.monitor_start_time = datetime.now() + else: + # Every other flow calls this function once per coordinate/rotation, with CXs + # freshly restarted just before each call - restart the grace period each time. + self.monitor_start_time = datetime.now() time_now = datetime.now() starttime = time_now.strftime("%d/%m %I:%M:%S %p") # duration = self.traffic_duration @@ -1010,6 +1031,11 @@ def monitor_for_runtime_csv(self, duration): "ending the monitor loop gracefully; the test will continue with " "the data collected so far.") end_monitor_loop = True + self.all_devices_stopped = True + if self.robot_test: + # Mark the WebUI as completed instead of leaving it at a later planned + # navigation state. + self.robot_obj.update_nav_data_for_all_cxs_stopped() else: l4_dict = self.get_layer4_data() @@ -2427,24 +2453,29 @@ def perform_robo(self): self.robot_obj.do_bandsteering = True self.start() for coordinate in cycle_coords: - if test_stopped_by_user: + if test_stopped_by_user or self.all_devices_stopped: break # Check for battery status before moving to next coordinate if_paused, test_stopped_by_user, test_status = self.robot_obj.wait_for_battery(monitor_function=lambda: self.monitor_for_runtime_csv(self.duration)) # If test is stopped by user during battery wait - if test_stopped_by_user: + if test_stopped_by_user or self.all_devices_stopped: break robo_moved, abort, test_status = self.robot_obj.move_to_coordinate(coordinate, monitor_function=lambda: self.monitor_for_runtime_csv(self.duration)) # If robot failed to reach the coordinate if abort: break + if self.all_devices_stopped: + logger.warning("Band-steering test stopped because no devices recovered within 40 seconds.") + break if robo_moved: logger.info("Reached the coordinate {}".format(coordinate)) self.stop() return for coordinate in range(len(self.coordinate_list)): # Check for battery status before moving to next coordinate - if test_stopped_by_user: + if test_stopped_by_user or self.all_devices_stopped: + if self.all_devices_stopped: + logger.warning("Robot test stopped because no devices recovered within 40 seconds.") break if_paused, test_stopped_by_user = self.robot_obj.wait_for_battery() # If test is stopped by user during battery wait @@ -2470,7 +2501,7 @@ def perform_robo(self): for angle in range(len(self.rotation_list)): # Check for battery status before rotating to next angle is_paused, test_stopped_by_user = self.robot_obj.wait_for_battery() - if test_stopped_by_user: + if test_stopped_by_user or self.all_devices_stopped: break robo_rotated = self.robot_obj.rotate_angle(self.rotation_list[angle]) if robo_rotated: @@ -2480,7 +2511,7 @@ def perform_robo(self): self.stop() self.update_stop_status_robot() # If test is stopped by user - if test_stopped_by_user: + if test_stopped_by_user or self.all_devices_stopped: break def build_graphs_and_table(self, coord="", rotation="", report="", lis=None, bands=None): From d57610d79f1738179f7f76813241d7473a8a058a Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Thu, 30 Jul 2026 15:03:30 +0530 Subject: [PATCH 6/7] lf_webpage.py: removed the lf_interop_utils depependency Signed-off-by: haricharan-candela --- py-scripts/lf_interop_utils.py | 142 --------------------------------- py-scripts/lf_webpage.py | 32 +++----- 2 files changed, 11 insertions(+), 163 deletions(-) delete mode 100644 py-scripts/lf_interop_utils.py diff --git a/py-scripts/lf_interop_utils.py b/py-scripts/lf_interop_utils.py deleted file mode 100644 index 1ec1fbd83..000000000 --- a/py-scripts/lf_interop_utils.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -""" -NAME: lf_interop_utils.py - -PURPOSE: -lf_interop_utils.py provides shared helpers for interop test scripts (lf_webpage.py, lf_ftp.py, -lf_interop_*.py, ...). It resolves LANforge layer4 API field names that vary -across server versions -- for example, 'rx rate (1m)' and 'rx-rate-1m' on different LANforge server builds. Requesting a name a given -server doesn't recognize fails the whole layer4 query and it utils prevents it. - -Run standalone to inspect what a given LANforge server actually calls each of these fields, -without needing to start a full test first (an existing layer4 CX must already be running on -the server, e.g. started by another script, for there to be anything to probe). - -EXAMPLE-1: -Command Line Interface to detect a LANforge server's layer4 field names -python3 lf_interop_utils.py --mgr 192.168.200.165 --get_l4_fields - -EXAMPLE-2: -Command Line Interface to probe a specific CX instead of auto-picking the first one found -python3 lf_interop_utils.py --mgr 192.168.200.165 --get_l4_fields --cx_name wlan0_http30_l4 -""" -import argparse -import importlib -import logging -import os -import sys - -if sys.version_info[0] != 3: - print("This script requires Python 3") - exit(1) - -sys.path.append(os.path.join(os.path.abspath(__file__ + "../../../"))) -realm = importlib.import_module("py-json.realm") -Realm = realm.Realm - -logger = logging.getLogger(__name__) - -# Known alternate spellings LANforge servers have used for the same layer4 column, newest known -# name first. Every field read from a layer4 record should have an entry here (even a -# single-alias one) so a future rename only needs a new alias added in one place. -LAYER4_FIELD_ALIASES = { - 'uc_avg': ['uc-avg'], - 'uc_max': ['uc-max'], - 'uc_min': ['uc-min'], - 'total_urls': ['total-urls'], - 'rx_rate_1m': ['rx-rate-1m', 'rx rate (1m)'], - 'tx_rate_1m': ['tx-rate-1m'], - 'bytes_rd': ['bytes-rd'], - 'total_err': ['total-err'], - 'status': ['status'], -} - - -def resolve_layer4_fields(local_realm, cx_name, field_keys, defaults=None): - """Probe one CX to find which known alias each field_key uses on this server, returning - {field_key: resolved_name}.""" - defaults = defaults or {} - candidates_by_key = {key: LAYER4_FIELD_ALIASES.get(key, [key]) for key in field_keys} - resolved = {key: defaults.get(key, candidates[0]) for key, candidates in candidates_by_key.items()} - if not cx_name: - return resolved - try: - # No 'fields' filter: the server returns every column it supports for this CX, so we - # can check which alias is present without risking an invalid-field-name error. - probe = local_realm.json_get('layer4/{}/list'.format(cx_name)) - endpoint = probe.get('endpoint') if probe else None - if isinstance(endpoint, list) and endpoint: - endpoint = list(endpoint[0].values())[0] - if isinstance(endpoint, dict): - for key, candidates in candidates_by_key.items(): - for candidate in candidates: - if candidate in endpoint: - resolved[key] = candidate - break - except Exception: - logger.warning("Could not probe layer4 columns, using default field names: %s", resolved) - return resolved - - -def layer4_fields_query(resolved_fields, field_keys): - """Build the comma-separated 'fields=' value for a layer4 list request, in order, from a - resolve_layer4_fields() result.""" - return ','.join(resolved_fields[key] for key in field_keys) - - -def find_any_cx_name(local_realm): - """Return the name of any one layer4 CX currently on the server, or None if there aren't any.""" - try: - response = local_realm.json_get('layer4/list') - endpoint = response.get('endpoint') if response else None - except Exception: - return None - if isinstance(endpoint, dict): - return endpoint.get('name') - if isinstance(endpoint, list) and endpoint: - return list(endpoint[0].keys())[0] - return None - - -def main(): - parser = argparse.ArgumentParser( - prog='lf_interop_utils.py', - formatter_class=argparse.RawTextHelpFormatter, - description=__doc__) - parser.add_argument('--mgr', help='hostname for where LANforge GUI is running [default = localhost]', default='localhost') - parser.add_argument('--mgr_port', help='port LANforge GUI HTTP service is running on [default = 8080]', type=int, default=8080) - parser.add_argument('--cx_name', help='name of an existing layer4 CX to probe; if omitted, the first CX found on the server is used', default=None) - parser.add_argument('--get_l4_fields', help='resolve and print this LANforge server\'s layer4 field names (e.g. rx-rate-1m vs rx rate (1m))', action='store_true') - parser.add_argument('--help_summary', action='store_true', help='Show summary of what this script does') - args = parser.parse_args() - - help_summary = '''\ -lf_interop_utils.py provides shared helpers for interop test scripts, most notably resolving -LANforge layer4 API field names (e.g. 'rx rate (1m)' vs 'rx-rate-1m') that vary across server -versions. Run with --get_l4_fields to detect what a given LANforge server calls these fields. -''' - if args.help_summary: - print(help_summary) - exit(0) - - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - if not args.get_l4_fields: - parser.print_help() - return - - local_realm = Realm(lfclient_host=args.mgr, lfclient_port=args.mgr_port) - cx_name = args.cx_name or find_any_cx_name(local_realm) - if not cx_name: - logger.error("No layer4 CXs found on %s:%s; start a test that creates one first, " - "or pass --cx_name explicitly.", args.mgr, args.mgr_port) - exit(1) - - fields = resolve_layer4_fields(local_realm, cx_name, LAYER4_FIELD_ALIASES.keys()) - print("Resolved layer4 field names on {}:{} (probed CX '{}'):".format(args.mgr, args.mgr_port, cx_name)) - for key, value in fields.items(): - print(" {:15s} -> {}".format(key, value)) - - -if __name__ == '__main__': - main() diff --git a/py-scripts/lf_webpage.py b/py-scripts/lf_webpage.py index 52e125623..97491494f 100755 --- a/py-scripts/lf_webpage.py +++ b/py-scripts/lf_webpage.py @@ -122,7 +122,6 @@ from typing import List, Optional import csv from lf_base_robo import RobotClass -from lf_interop_utils import resolve_layer4_fields, layer4_fields_query sys.path.append(os.path.join(os.path.abspath(__file__ + "../../../"))) @@ -142,10 +141,6 @@ logger = logging.getLogger(__name__) -# Logical layer4 fields read from a CX record by get_layer4_data()/get_all_l4_data(), resolved -# to this server's actual column names via lf_interop_utils.resolve_layer4_fields(). -L4_FIELD_KEYS = ('uc_avg', 'uc_max', 'uc_min', 'total_urls', 'rx_rate_1m', 'bytes_rd', 'total_err', 'status') - iot_scripts_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../local/interop-webGUI/IoT/scripts/")) if os.path.exists(iot_scripts_path): sys.path.insert(0, iot_scripts_path) @@ -267,7 +262,6 @@ def __init__(self, lfclient_host, lfclient_port, upstream, num_sta, security, ss self.device_issue_log = [] self.monitor_start_time = None self.actual_monitor_duration = 0 - self.l4_fields = None # Set when a monitor_for_runtime_csv() recovery wait times out with every CX still # unresponsive, so perform_robo() stops moving to further coordinates/rotations instead # of continuing a robot test none of the devices can respond to. @@ -748,11 +742,8 @@ def get_layer4_data(self): dict: mapping of metric names to lists of values, one per CX. """ cx_list = list(self.http_profile.created_cx.keys()) - if self.l4_fields is None and cx_list: - self.l4_fields = resolve_layer4_fields(self.local_realm, cx_list[0], L4_FIELD_KEYS) - fields = self.l4_fields or resolve_layer4_fields(self.local_realm, None, L4_FIELD_KEYS) try: - url_str = 'layer4/{}/list?fields={}'.format(','.join(cx_list), layer4_fields_query(fields, L4_FIELD_KEYS)) + url_str = 'layer4/{}/list?fields=uc-avg,uc-max,uc-min,total-urls,rx rate (1m),bytes-rd,total-err,status'.format(','.join(cx_list)) response = self.local_realm.json_get(url_str) endpoint_data = response.get("endpoint") if response else None if endpoint_data is None: @@ -779,15 +770,15 @@ def get_layer4_data(self): for i in endpoint_data: for cx_name, value in i.items(): if cx == cx_name: - l4_dict['uc_avg_data'].append(value[fields['uc_avg']]) - l4_dict['uc_max_data'].append(value[fields['uc_max']]) - l4_dict['uc_min_data'].append(value[fields['uc_min']]) - l4_dict['url_times'].append(value[fields['total_urls']]) - l4_dict['rx_rate'].append(value[fields['rx_rate_1m']]) - l4_dict['bytes_rd'].append(value[fields['bytes_rd']]) - l4_dict['total_err'].append(value[fields['total_err']]) - l4_dict['status'].append(value.get(fields['status'], '')) - self.track_cx_status(cx, value.get(fields['status'], '')) + l4_dict['uc_avg_data'].append(value['uc-avg']) + l4_dict['uc_max_data'].append(value['uc-max']) + l4_dict['uc_min_data'].append(value['uc-min']) + l4_dict['url_times'].append(value['total-urls']) + l4_dict['rx_rate'].append(value['rx rate (1m)']) + l4_dict['bytes_rd'].append(value['bytes-rd']) + l4_dict['total_err'].append(value['total-err']) + l4_dict['status'].append(value.get('status', '')) + self.track_cx_status(cx, value.get('status', '')) cx_found = True if not cx_found: if cx not in self.missing_cx_logged: @@ -1167,12 +1158,11 @@ def get_all_l4_data(self): Returns: dict: A dictionary mapping each Layer 4 field to a list of values in the order of CXs. """ - rx_rate_1m_field = (self.l4_fields or {}).get('rx_rate_1m', 'rx-rate-1m') fields = [ "name", "eid", "type", "status", "total-urls", "urls/s", "bytes-rd", "bytes-wr", "total-buffers", "total-rebuffers", "total-wait-time", "video-format-bitrate", "audio-format-bitrate", "frame-rate", "video-quality", "tx rate", "tx-rate-1m", - "rx rate", rx_rate_1m_field, "fb-min", "fb-avg", "fb-max", "uc-min", "uc-avg", + "rx rate", "rx rate (1m)", "fb-min", "fb-avg", "fb-max", "uc-min", "uc-avg", "uc-max", "dns-min", "dns-avg", "dns-max", "total-err", "bad-proto", "bad-url", "rslv-p", "rslv-h", "!conn", "timeout", "nf (4xx)", "http-r", "http-p", "http-t", "acc. denied", "ftp-host", "ftp-stor", "ftp-port", "write", "read", "redir", From 6107174d39b80f457de69102027eee7521c2b96b Mon Sep 17 00:00:00 2001 From: haricharan-candela Date: Thu, 30 Jul 2026 17:58:51 +0530 Subject: [PATCH 7/7] lf_webpage.py: Removed unnecessary comments VERIFIED CLI: python3 lf_webpage.py --mgr 192.168.207.75 --upstream_port eth1 --duration 10m --bands 5G --client_type Real --file_size 2MB Signed-off-by: haricharan-candela --- py-scripts/lf_webpage.py | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/py-scripts/lf_webpage.py b/py-scripts/lf_webpage.py index 97491494f..212c6b106 100755 --- a/py-scripts/lf_webpage.py +++ b/py-scripts/lf_webpage.py @@ -262,9 +262,6 @@ def __init__(self, lfclient_host, lfclient_port, upstream, num_sta, security, ss self.device_issue_log = [] self.monitor_start_time = None self.actual_monitor_duration = 0 - # Set when a monitor_for_runtime_csv() recovery wait times out with every CX still - # unresponsive, so perform_robo() stops moving to further coordinates/rotations instead - # of continuing a robot test none of the devices can respond to. self.all_devices_stopped = False # The 'phantom_check' will be handled within the 'get_real_client_list' function @@ -614,7 +611,7 @@ def precleanup(self): print("precleanup done") def get_upstream_ip(self): - """Gives the upstream ip""" + """Gives the upstream ip.""" data = self.local_realm.json_get("ports/list?fields=IP") eid = self.local_realm.name_to_eid(self.upstream) @@ -801,9 +798,6 @@ def get_layer4_data(self): l4_dict['bytes_rd'].append(0 if not self.tracking_map else self.tracking_map['bytes_rd'][idx]) l4_dict['total_err'].append(0 if not self.tracking_map else self.tracking_map['total_err'][idx]) l4_dict['status'].append('Stopped') - # Don't route through track_cx_status here: the "CX missing" warning/issue above - # already records this event, so this just keeps cx_status_log's baseline in - # sync without writing a second, redundant issue-log entry. if self.monitoring_elapsed_seconds() >= 10: self.cx_status_log[cx] = 'Stopped' elif cx in self.missing_cx_logged: @@ -827,9 +821,7 @@ def monitoring_elapsed_seconds(self): return (datetime.now() - self.monitor_start_time).total_seconds() def track_cx_status(self, cx, status): - # Ignore CX status for the first 10s of monitoring: CXs are still settling into "Run" - # right after the test starts, and treating that startup ramp-up as a real status - # change/recovery would be a false positive. + """Tracks the CXs status and logs any changes.""" if not status or self.monitoring_elapsed_seconds() < 10: return previous = self.cx_status_log.get(cx) @@ -843,20 +835,14 @@ def track_cx_status(self, cx, status): self.cx_status_log[cx] = status def format_monitoring_duration(self): + """Formats the actual monitoring duration into a human-readable string.""" total_seconds = int(self.actual_monitor_duration) minutes, seconds = divmod(total_seconds, 60) return "{}m {}s".format(minutes, seconds) def wait_for_any_cx_recovery(self, timeout=40, poll_interval=5): """ - Polls layer4 data (via get_layer4_data) while every created CX is missing, giving - devices a chance to reappear before the caller gives up on this monitoring iteration. - Also honors a user-initiated stop from the webgui during the wait, so a stop request - isn't delayed by the full retry window. - - Returns 'recovered' as soon as at least one CX responds again, 'stopped' if the user - stops the test during the wait, or 'timeout' if `timeout` seconds elapse with every CX - still missing. + Waits for any of the created CXs to recover (i.e., stop being missing" from the monitoring data) within a specified timeout. """ wait_start = datetime.now() created_cx_count = len(self.http_profile.created_cx) @@ -913,10 +899,8 @@ def aggregate_rx_bytes(self, rx_rate, bytes_rd): return list(rx_rate), list(bytes_rd) def monitor_for_runtime_csv(self, duration): + """Monitor the Layer 4 connections for a specified duration, collecting data and handling device issues.""" if self.all_devices_stopped: - # A previous call already gave up waiting for devices to recover. Band steering - # invokes this function repeatedly as its own per-tick callback, so return - # immediately instead of re-running the 40s recovery wait on every tick. return True if self.do_bandsteering: @@ -925,8 +909,6 @@ def monitor_for_runtime_csv(self, duration): if self.monitor_start_time is None: self.monitor_start_time = datetime.now() else: - # Every other flow calls this function once per coordinate/rotation, with CXs - # freshly restarted just before each call - restart the grace period each time. self.monitor_start_time = datetime.now() time_now = datetime.now() starttime = time_now.strftime("%d/%m %I:%M:%S %p") @@ -1002,12 +984,6 @@ def monitor_for_runtime_csv(self, duration): # total_url_data = self.json_get("layer4/list?fields=total-urls") # bytes_rd = self.json_get("layer4/list?fields=bytes-rd") l4_dict = self.get_layer4_data() - - # If every CX has stopped responding, retry for up to 40 seconds before giving up - # on this monitor loop. This does not fail the test: the data below is still - # assembled (get_layer4_data() already fills in zero/previous-value fallbacks for - # every CX) so self.data keeps all its expected keys, and the loop only ends - # *after* that data is saved, same as the existing webgui-stop check below. end_monitor_loop = False created_cx_count = len(self.http_profile.created_cx) if created_cx_count and len(self.missing_cx_logged) == created_cx_count: @@ -3489,7 +3465,6 @@ def main(): # FOR WEBGUI, filling csv at the end to get the last terminal logs if args.dowebgui: http.copy_reports_to_home_dir() - logger.info("successfully ran the http test") if __name__ == '__main__':