From b577ddb31e8839f798e6a0e7b039b60ec0bed8ba Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 30 Jun 2025 13:34:19 +0200 Subject: [PATCH 001/156] add warning to batch mode --- custom-recipes/pi-system-retrieve-list/recipe.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-recipes/pi-system-retrieve-list/recipe.json b/custom-recipes/pi-system-retrieve-list/recipe.json index a7425143..d30a1e49 100644 --- a/custom-recipes/pi-system-retrieve-list/recipe.json +++ b/custom-recipes/pi-system-retrieve-list/recipe.json @@ -105,7 +105,7 @@ "name": "use_batch_mode", "label": "Use batch mode", "type": "BOOLEAN", - "description": "", + "description": "Use to quickly retrieve small samples from multiple paths. ⚠️Not for large time ranges", "visibilityCondition": "model.show_advanced_parameters==true", "defaultValue": false }, From 2a81e81a898b2cf0e53d6f45c31c85096e490252 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 30 Jun 2025 13:46:13 +0200 Subject: [PATCH 002/156] add mx request size selector in preset --- parameter-sets/basic-auth/parameter-set.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/parameter-sets/basic-auth/parameter-set.json b/parameter-sets/basic-auth/parameter-set.json index 13e76de8..fb91c70e 100644 --- a/parameter-sets/basic-auth/parameter-set.json +++ b/parameter-sets/basic-auth/parameter-set.json @@ -50,6 +50,13 @@ "description": "(optional)", "defaultValue": "" }, + { + "name": "max_request_size", + "label": "Maximum request size", + "type": "INT", + "description": "", + "defaultValue": 1000 + }, { "name": "osisoft_basic", "type": "CREDENTIAL_REQUEST", From 5fcd7ba10729bae3cd36c5e613470b21a37e7025 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 30 Jul 2025 10:56:53 +0700 Subject: [PATCH 003/156] add batch time limitation to preset --- parameter-sets/basic-auth/parameter-set.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/parameter-sets/basic-auth/parameter-set.json b/parameter-sets/basic-auth/parameter-set.json index fb91c70e..70c278c9 100644 --- a/parameter-sets/basic-auth/parameter-set.json +++ b/parameter-sets/basic-auth/parameter-set.json @@ -57,6 +57,21 @@ "description": "", "defaultValue": 1000 }, + { + "name": "estimated_density", + "label": "Estimated point density", + "type": "DOUBLE", + "description": "points/hour", + "defaultValue": 2 + }, + { + "name": "maximum_points_returned", + "label": "Maximum points return", + "type": "INT", + "description": "Target optimum number of points returned by batch. Calculated based on point density.", + "defaultValue": 1000000, + "minI": 1 + }, { "name": "osisoft_basic", "type": "CREDENTIAL_REQUEST", From 7adfea1ae4a32dab450f0f0bbb750c381f9db3cf Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 4 Aug 2025 23:02:34 +0700 Subject: [PATCH 004/156] move BatchTimeCounter to commons --- python-lib/osisoft_plugin_common.py | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 2a160dda..e7c920cb 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -56,6 +56,21 @@ def get_credentials(config, can_raise=True): return auth_type, username, password, server_url, is_ssl_check_disabled, error_message +def get_batch_parameters(config): + credentials = config.get("credentials", {}) + max_request_size = credentials.get("max_request_size", 1000) + estimated_density = credentials.get("estimated_density", 6000) + maximum_points_returned = credentials.get("maximum_points_returned", 1000000) + return max_request_size, estimated_density, maximum_points_returned + + +def compute_time_spent(start, end, bla): + # 2023-06-30T13:05:10.8692786Z->2024-06-30T13:05:10.9640942Z + start = iso_to_epoch(start) + end = iso_to_epoch(end) + return end - start + + def get_advanced_parameters(config): show_advanced_parameters = config.get('show_advanced_parameters', False) batch_size = 500 @@ -600,3 +615,28 @@ def get_worst_performers(self): for slowest_event, slowest_time in zip(self.slowest_events, self.slowest_times): worst_performers.append("{}: {}s".format(slowest_event, slowest_time)) return worst_performers + + +class BatchTimeCounter(object): + def __init__(self, max_time_to_retrieve_per_batch): + logger.info("ALX:max_time_to_retrieve_per_batch:{}".format(max_time_to_retrieve_per_batch * 60 * 60)) + self.max_time_to_retrieve_per_batch = max_time_to_retrieve_per_batch * 60 * 60 + self.total_batch_time = 0 + # 2 points /h each line + # max 1 000 000 lines back -> 500k hours max + + def is_batch_full(self): + # return False + if self.max_time_to_retrieve_per_batch < 0: + return False + if self.total_batch_time > self.max_time_to_retrieve_per_batch: + logger.warning("batch contains {}s of request, needs to flush now".format(self.total_batch_time)) + self.total_batch_time = 0 + return True + logger.info("Batch below time threshold") + return False + + def add(self, start_time, end_time, interval): + print("ALX:add time {}, {}, {}, {}".format(start_time, end_time, interval, self.total_batch_time)) + self.total_batch_time += compute_time_spent(start_time, end_time, interval) + print("ALX:added time {}".format(self.total_batch_time)) From 938c041fd8a138258c9941ce999bd3a0bab812d7 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 4 Aug 2025 23:04:07 +0700 Subject: [PATCH 005/156] cleaning --- python-lib/osisoft_plugin_common.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index e7c920cb..a4aff592 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -619,7 +619,6 @@ def get_worst_performers(self): class BatchTimeCounter(object): def __init__(self, max_time_to_retrieve_per_batch): - logger.info("ALX:max_time_to_retrieve_per_batch:{}".format(max_time_to_retrieve_per_batch * 60 * 60)) self.max_time_to_retrieve_per_batch = max_time_to_retrieve_per_batch * 60 * 60 self.total_batch_time = 0 # 2 points /h each line @@ -637,6 +636,4 @@ def is_batch_full(self): return False def add(self, start_time, end_time, interval): - print("ALX:add time {}, {}, {}, {}".format(start_time, end_time, interval, self.total_batch_time)) self.total_batch_time += compute_time_spent(start_time, end_time, interval) - print("ALX:added time {}".format(self.total_batch_time)) From efc1c2baf144d65846c0402d26847e8aea040f83 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 4 Aug 2025 23:36:02 +0700 Subject: [PATCH 006/156] add limiter to recipe --- custom-recipes/pi-system-retrieve-list/recipe.py | 10 ++++++++-- python-lib/osisoft_client.py | 12 +++++++++--- python-lib/osisoft_plugin_common.py | 3 +++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/custom-recipes/pi-system-retrieve-list/recipe.py b/custom-recipes/pi-system-retrieve-list/recipe.py index 88b4fbb8..611dfb6b 100644 --- a/custom-recipes/pi-system-retrieve-list/recipe.py +++ b/custom-recipes/pi-system-retrieve-list/recipe.py @@ -7,7 +7,8 @@ get_credentials, get_interpolated_parameters, normalize_af_path, get_combined_description, get_base_for_data_type, check_debug_mode, PerformanceTimer, get_max_count, check_must_convert_object_to_string, - convert_schema_objects_to_string, get_summary_parameters, get_advanced_parameters + convert_schema_objects_to_string, get_summary_parameters, get_advanced_parameters, + get_batch_parameters ) from osisoft_client import OSIsoftClient from osisoft_constants import OSIsoftConstants @@ -63,6 +64,8 @@ def get_step_value(item): record_boundary_type = config.get("record_boundary_type") if data_type == "RecordedData" else None summary_type, summary_duration = get_summary_parameters(config) do_duplicate_input_row = config.get("do_duplicate_input_row", False) +max_request_size, estimated_density, maximum_points_returned = get_batch_parameters(config) +max_time_to_retrieve_per_batch = estimated_density / maximum_points_returned #density per hour <- max time is in hour network_timer = PerformanceTimer() processing_timer = PerformanceTimer() @@ -150,9 +153,12 @@ def get_step_value(item): object_id=object_id, summary_type=summary_type, summary_duration=summary_duration, - endpoint_type="AF" + endpoint_type="AF", + estimated_density=estimated_density, + maximum_points_returned=maximum_points_returned ) batch_buffer_size = 0 + total_batch_time = 0 buffer = [] else: continue diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 1fbf55ce..012f4af2 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -10,7 +10,8 @@ from osisoft_plugin_common import ( assert_server_url_ok, build_requests_params, is_filtered_out, is_server_throttling, escape, epoch_to_iso, - iso_to_epoch, RecordsLimit, is_iso8601, get_next_page_url, change_key_in_dict + iso_to_epoch, RecordsLimit, is_iso8601, get_next_page_url, change_key_in_dict, + BatchTimeCounter ) from osisoft_pagination import OffsetPagination from safe_logger import SafeLogger @@ -243,7 +244,10 @@ def get_rows_from_webid(self, webid, data_type, **kwargs): def get_rows_from_webids(self, input_rows, data_type, **kwargs): endpoint_type = kwargs.get("endpoint_type", "event_frames") batch_size = kwargs.get("batch_size", 500) - + estimated_density = kwargs.get("estimated_density", 500) + maximum_points_returned = kwargs.get("maximum_points_returned", 500) + max_time_to_retrieve_per_batch = maximum_points_returned / estimated_density + batch_time = BatchTimeCounter(max_time_to_retrieve_per_batch) batch_requests_parameters = [] number_processed_webids = 0 number_of_webids_to_process = len(input_rows) @@ -260,13 +264,15 @@ def get_rows_from_webids(self, input_rows, data_type, **kwargs): webid = input_row url = self.endpoint.get_data_from_webid_url(endpoint_type, data_type, webid) requests_kwargs = self.generic_get_kwargs(**kwargs) + print("ALX:requests_kwargs={}".format(requests_kwargs)) + batch_time.add(requests_kwargs.get("params", {}).get("starttime"), requests_kwargs.get("params",{}).get("endtime"), None) requests_kwargs['url'] = build_query_string(url, requests_kwargs.get("params")) web_ids.append(webid) event_start_times.append(event_start_time) event_end_times.append(event_end_time) batch_requests_parameters.append(requests_kwargs) number_processed_webids += 1 - if (len(batch_requests_parameters) >= batch_size) or (number_processed_webids == number_of_webids_to_process): + if (len(batch_requests_parameters) >= batch_size) or (number_processed_webids == number_of_webids_to_process) or batch_time.is_batch_full(): json_responses = self._batch_requests(batch_requests_parameters) batch_requests_parameters = [] response_index = 0 diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index a4aff592..e7c920cb 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -619,6 +619,7 @@ def get_worst_performers(self): class BatchTimeCounter(object): def __init__(self, max_time_to_retrieve_per_batch): + logger.info("ALX:max_time_to_retrieve_per_batch:{}".format(max_time_to_retrieve_per_batch * 60 * 60)) self.max_time_to_retrieve_per_batch = max_time_to_retrieve_per_batch * 60 * 60 self.total_batch_time = 0 # 2 points /h each line @@ -636,4 +637,6 @@ def is_batch_full(self): return False def add(self, start_time, end_time, interval): + print("ALX:add time {}, {}, {}, {}".format(start_time, end_time, interval, self.total_batch_time)) self.total_batch_time += compute_time_spent(start_time, end_time, interval) + print("ALX:added time {}".format(self.total_batch_time)) From 5b9ee8f707aac1cf5d6535bb693d1979a4807012 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 5 Aug 2025 13:54:49 +0700 Subject: [PATCH 007/156] cleaning --- custom-recipes/pi-system-retrieve-list/recipe.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom-recipes/pi-system-retrieve-list/recipe.py b/custom-recipes/pi-system-retrieve-list/recipe.py index 611dfb6b..b0f3c1cb 100644 --- a/custom-recipes/pi-system-retrieve-list/recipe.py +++ b/custom-recipes/pi-system-retrieve-list/recipe.py @@ -158,7 +158,6 @@ def get_step_value(item): maximum_points_returned=maximum_points_returned ) batch_buffer_size = 0 - total_batch_time = 0 buffer = [] else: continue From 11be561e3ccb8d3946cdbfe17b516badaae8e66e Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 5 Aug 2025 14:28:33 +0700 Subject: [PATCH 008/156] cleaning --- python-lib/osisoft_plugin_common.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index e7c920cb..2c84fc14 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -442,9 +442,9 @@ def epoch_to_iso(epoch): def iso_to_epoch(iso_timestamp): - logger.info("Converting iso timestamp '{}' to epoch".format(iso_timestamp)) + # logger.info("Converting iso timestamp '{}' to epoch".format(iso_timestamp)) if is_epoch(iso_timestamp): - logger.info("Timestamp is already epoch") + # logger.info("Timestamp is already epoch") return iso_timestamp epoch_timestamp = None try: @@ -453,7 +453,7 @@ def iso_to_epoch(iso_timestamp): except Exception: logger.error("Error when converting iso timestamp '{}' to epoch".format(iso_timestamp)) return None - logger.info("Timestamp is now '{}'".format(epoch_timestamp)) + # logger.info("Timestamp is now '{}'".format(epoch_timestamp)) return epoch_timestamp @@ -619,24 +619,19 @@ def get_worst_performers(self): class BatchTimeCounter(object): def __init__(self, max_time_to_retrieve_per_batch): - logger.info("ALX:max_time_to_retrieve_per_batch:{}".format(max_time_to_retrieve_per_batch * 60 * 60)) + logger.info("BatchTimeCounter:max_time_to_retrieve_per_batch={}s".format(max_time_to_retrieve_per_batch * 60 * 60)) self.max_time_to_retrieve_per_batch = max_time_to_retrieve_per_batch * 60 * 60 - self.total_batch_time = 0 - # 2 points /h each line - # max 1 000 000 lines back -> 500k hours max + self.total_batched_time = 0 def is_batch_full(self): - # return False if self.max_time_to_retrieve_per_batch < 0: return False - if self.total_batch_time > self.max_time_to_retrieve_per_batch: - logger.warning("batch contains {}s of request, needs to flush now".format(self.total_batch_time)) - self.total_batch_time = 0 + if self.total_batched_time > self.max_time_to_retrieve_per_batch: + logger.warning("batch contains {}s of request, needs to flush now".format(self.total_batched_time)) + self.total_batched_time = 0 return True - logger.info("Batch below time threshold") return False def add(self, start_time, end_time, interval): - print("ALX:add time {}, {}, {}, {}".format(start_time, end_time, interval, self.total_batch_time)) - self.total_batch_time += compute_time_spent(start_time, end_time, interval) - print("ALX:added time {}".format(self.total_batch_time)) + print("ALX:adding start_time={}, end_time={}, interval={}".format(start_time, end_time, interval)) + self.total_batched_time += compute_time_spent(start_time, end_time, interval) From 2337c49f0347711411ce0d3e41babfcddece40bc Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 5 Aug 2025 14:28:43 +0700 Subject: [PATCH 009/156] getting time from kwargs --- python-lib/osisoft_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 012f4af2..8b10b9d7 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -263,9 +263,11 @@ def get_rows_from_webids(self, input_rows, data_type, **kwargs): else: webid = input_row url = self.endpoint.get_data_from_webid_url(endpoint_type, data_type, webid) + start_date = kwargs.get("start_date") + end_date = kwargs.get("end_date") + interval = kwargs.get("interval") requests_kwargs = self.generic_get_kwargs(**kwargs) - print("ALX:requests_kwargs={}".format(requests_kwargs)) - batch_time.add(requests_kwargs.get("params", {}).get("starttime"), requests_kwargs.get("params",{}).get("endtime"), None) + batch_time.add(start_date, end_date, interval) requests_kwargs['url'] = build_query_string(url, requests_kwargs.get("params")) web_ids.append(webid) event_start_times.append(event_start_time) From 5ca5a62f154adcf7d84dfd798edfab334a03c875 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 27 Aug 2025 09:31:34 +0200 Subject: [PATCH 010/156] removing dev log --- python-lib/osisoft_plugin_common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 2c84fc14..c8e08155 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -633,5 +633,4 @@ def is_batch_full(self): return False def add(self, start_time, end_time, interval): - print("ALX:adding start_time={}, end_time={}, interval={}".format(start_time, end_time, interval)) self.total_batched_time += compute_time_spent(start_time, end_time, interval) From 96aa549786d2bfe352abb1cade408c028e7db3d8 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 22 Sep 2025 16:42:24 +0200 Subject: [PATCH 011/156] v1.4.0 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 99372645..517456c3 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "pi-system", - "version": "1.3.1", + "version": "1.4.0", "meta": { "label": "PI System", "description": "Retrieve data from your OSIsoft PI System servers", From 04383e918854409028dcc42d276271d243dae1d8 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 22 Sep 2025 16:42:33 +0200 Subject: [PATCH 012/156] beta.1 --- python-lib/osisoft_constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-lib/osisoft_constants.py b/python-lib/osisoft_constants.py index ee6fae49..20c8efae 100644 --- a/python-lib/osisoft_constants.py +++ b/python-lib/osisoft_constants.py @@ -405,7 +405,7 @@ class OSIsoftConstants(object): "Security": "{base_url}/eventframes/{webid}/security", "SecurityEntries": "{base_url}/eventframes/{webid}/securityentries" } - PLUGIN_VERSION = "1.3.1" + PLUGIN_VERSION = "1.4.0-beta.1" VALUE_COLUMN_SUFFIX = "_val" WEB_API_PATH = "piwebapi" WRITE_HEADERS = {'X-Requested-With': 'XmlHttpRequest'} From db822e015aad687e3e72be6024fb5c5e5e40d942 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 22 Sep 2025 16:42:41 +0200 Subject: [PATCH 013/156] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c623381e..2fc652ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Version 1.4.0](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.4.0) - Feature release - 2025-09-22 + +- Add write recipe +- Add download limiter by timestamp density + ## [Version 1.3.1](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.3.1) - Bugfix release - 2025-05-24 - Fix the mix mode interpolation type in Transpose & Synchronise (the Step value was inverted) From 8c21276c2fbffe50ad77940dc094d473f196095c Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 22 Sep 2025 16:47:24 +0200 Subject: [PATCH 014/156] commenting out broken ssl test --- tests/python/integration/test_scenario.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/integration/test_scenario.py b/tests/python/integration/test_scenario.py index aeac16a9..64ef3d72 100644 --- a/tests/python/integration/test_scenario.py +++ b/tests/python/integration/test_scenario.py @@ -7,8 +7,8 @@ def test_run_pisystem_authentication_modes(user_dss_clients): dss_scenario.run(user_dss_clients, project_key=TEST_PROJECT_KEY, scenario_id="AuthenticationModes") -def test_run_pisystem_ssl_certificate(user_dss_clients): - dss_scenario.run(user_dss_clients, project_key=TEST_PROJECT_KEY, scenario_id="SSLCERTIFICATE") +# def test_run_pisystem_ssl_certificate(user_dss_clients): + # dss_scenario.run(user_dss_clients, project_key=TEST_PROJECT_KEY, scenario_id="SSLCERTIFICATE") def test_run_pisystem_asset_search_and_download(user_dss_clients): From ceb6c2b6d20d850c04a16fc89c1b209e7e2a14e5 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Fri, 24 Oct 2025 17:55:00 +0200 Subject: [PATCH 015/156] step 1 - download whole hierarchy --- .../pi-system_hierarchy/connector.json | 91 +++++++++++++++++ .../pi-system_hierarchy/connector.py | 99 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 python-connectors/pi-system_hierarchy/connector.json create mode 100644 python-connectors/pi-system_hierarchy/connector.py diff --git a/python-connectors/pi-system_hierarchy/connector.json b/python-connectors/pi-system_hierarchy/connector.json new file mode 100644 index 00000000..41851559 --- /dev/null +++ b/python-connectors/pi-system_hierarchy/connector.json @@ -0,0 +1,91 @@ +{ + "meta" : { + "label": "AF Hierarchy", + "description": "", + "icon": "icon-pi-system icon-cogs" + }, + "readable": true, + "writable": false, + "supportAppend": false, + "kind": "PYTHON", + "paramsPythonSetup": "browse_event_frames.py", + "params": [ + { + "name": "credentials", + "label": "User preset", + "type": "PRESET", + "parameterSetId": "basic-auth" + }, + { + "name": "show_advanced_parameters", + "label": " ", + "type": "BOOLEAN", + "description": "Show advanced parameters", + "defaultValue": false + }, + { + "name": "server_url", + "label": "Server URL", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "STRING", + "description": "https://my_server:8082", + "defaultValue": "" + }, + { + "name": "is_ssl_check_disabled", + "label": " ", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "BOOLEAN", + "description": "Disable SSL check", + "defaultValue": false + }, + { + "name": "ssl_cert_path", + "label": "Path to SSL certificate", + "type": "STRING", + "description": "(optional)", + "visibilityCondition": "model.show_advanced_parameters==true && model.is_ssl_check_disabled==false", + "mandatory": false + }, + { + "name": "is_debug_mode", + "label": " ", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "BOOLEAN", + "description": "Verbose logging", + "defaultValue": false + }, + { + "name": "use_batch_mode", + "label": " ", + "type": "BOOLEAN", + "description": "Use batch mode", + "visibilityCondition": "model.show_advanced_parameters==true && model.must_retrieve_metrics==true", + "defaultValue": false + }, + { + "name": "batch_size", + "label": " ", + "type": "INT", + "description": "Batch size", + "visibilityCondition": "model.show_advanced_parameters==true && model.use_batch_mode==true && model.must_retrieve_metrics==true", + "minI": 1, + "defaultValue": 500 + }, + { + "name": "server_name", + "label": "Server name", + "type": "SELECT", + "description": "", + "getChoicesFromPython": true + }, + { + "name": "database_name", + "label": "Database name", + "type": "SELECT", + "description": "", + "visibilityCondition": "model.server_name.length>=0", + "getChoicesFromPython": true + } + ] +} diff --git a/python-connectors/pi-system_hierarchy/connector.py b/python-connectors/pi-system_hierarchy/connector.py new file mode 100644 index 00000000..030d73bc --- /dev/null +++ b/python-connectors/pi-system_hierarchy/connector.py @@ -0,0 +1,99 @@ +from dataiku.connector import Connector +from osisoft_client import OSIsoftClient +from safe_logger import SafeLogger +from osisoft_plugin_common import ( + RecordsLimit, get_credentials, + check_debug_mode, PerformanceTimer +) +from osisoft_constants import OSIsoftConstants + + +logger = SafeLogger("PI System plugin", ["user", "password"]) + + +class HierarchyConnector(Connector): + + def __init__(self, config, plugin_config): + Connector.__init__(self, config, plugin_config) + + logger.info("Attribute search v{} initialization with config={}, plugin_config={}".format( + OSIsoftConstants.PLUGIN_VERSION, logger.filter_secrets(config), logger.filter_secrets(plugin_config) + )) + + auth_type, username, password, server_url, is_ssl_check_disabled = get_credentials(config) + is_debug_mode = check_debug_mode(config) + self.database_endpoint = config.get("database_name") + + self.network_timer = PerformanceTimer() + self.client = OSIsoftClient( + server_url, auth_type, username, password, + is_ssl_check_disabled=is_ssl_check_disabled, + is_debug_mode=is_debug_mode, + network_timer=self.network_timer + ) + + def get_read_schema(self): + return None + + def generate_rows(self, dataset_schema=None, dataset_partitioning=None, + partition_id=None, records_limit = -1): + limit = RecordsLimit(records_limit) + + headers = self.client.get_requests_headers() + json_response = self.client.get(url=self.database_endpoint, headers=headers, params={}, error_source="traverse") + next_url = self.client.extract_link_with_key(json_response, "Elements") + + for item in self.recurse_next_item(next_url): + if limit.is_reached(): + break + yield item + + def recurse_next_item(self, next_url, parent=None, type=None): + logger.info("recurse_next_item") + type = type or "Elements" + headers = self.client.get_requests_headers() + json_response = self.client.get(url=next_url, headers=headers, params={}, error_source="recurse") + items = json_response.get("Items") + if not items: + return + for item in items: + parent_path = item.get("Path") + link_to_attributes = self.client.extract_link_with_key(item, "Attributes") + if link_to_attributes: + for attribute in self.recurse_next_item(link_to_attributes, parent=parent_path, type="Attribute"): + yield attribute + link_to_elements = self.client.extract_link_with_key(item, "Elements") + if link_to_elements: + for element in self.recurse_next_item(link_to_elements, parent=parent_path, type="Element"): + yield element + yield { + "ItemType": type, + "Name": item.get("Name"), + "Type": item.get("Type"), + "Description": item.get("Description"), + "Path": item.get("Path"), + "Parent": parent, + "DefaultUnitsName": item.get("DefaultUnitsName"), + "TemplateName": item.get("TemplateName"), + "CategoryNames": item.get("CategoryNames"), + "ExtendedProperties": item.get("ExtendedProperties"), + "Step": item.get("Step"), + "WebId": item.get("WebId"), + "Id": item.get("Id") + } + + def get_writer(self, dataset_schema=None, dataset_partitioning=None, + partition_id=None, write_mode="OVERWRITE"): + raise NotImplementedError + + def get_partitioning(self): + raise NotImplementedError + + def list_partitions(self, partitioning): + return [] + + def partition_exists(self, partitioning, partition_id): + raise NotImplementedError + + def get_records_count(self, partitioning=None, partition_id=None): + raise NotImplementedError From 3e2f410b292416ba25af28e9f94893bbfdcd02dd Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 27 Oct 2025 17:13:20 +0100 Subject: [PATCH 016/156] add batch processing to AF hierarchy downloader --- .../pi-system_hierarchy/connector.json | 4 +- .../pi-system_hierarchy/connector.py | 159 +++++++++++++++++- 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/python-connectors/pi-system_hierarchy/connector.json b/python-connectors/pi-system_hierarchy/connector.json index 41851559..31754ee7 100644 --- a/python-connectors/pi-system_hierarchy/connector.json +++ b/python-connectors/pi-system_hierarchy/connector.json @@ -60,7 +60,7 @@ "label": " ", "type": "BOOLEAN", "description": "Use batch mode", - "visibilityCondition": "model.show_advanced_parameters==true && model.must_retrieve_metrics==true", + "visibilityCondition": "model.show_advanced_parameters==true", "defaultValue": false }, { @@ -68,7 +68,7 @@ "label": " ", "type": "INT", "description": "Batch size", - "visibilityCondition": "model.show_advanced_parameters==true && model.use_batch_mode==true && model.must_retrieve_metrics==true", + "visibilityCondition": "model.show_advanced_parameters==true && model.use_batch_mode==true", "minI": 1, "defaultValue": 500 }, diff --git a/python-connectors/pi-system_hierarchy/connector.py b/python-connectors/pi-system_hierarchy/connector.py index 030d73bc..bcf49bdb 100644 --- a/python-connectors/pi-system_hierarchy/connector.py +++ b/python-connectors/pi-system_hierarchy/connector.py @@ -31,6 +31,8 @@ def __init__(self, config, plugin_config): is_debug_mode=is_debug_mode, network_timer=self.network_timer ) + self.use_batch_mode = config.get("use_batch_mode", False) + self.batch_size = config.get("batch_size", 500) def get_read_schema(self): return None @@ -41,12 +43,18 @@ def generate_rows(self, dataset_schema=None, dataset_partitioning=None, headers = self.client.get_requests_headers() json_response = self.client.get(url=self.database_endpoint, headers=headers, params={}, error_source="traverse") - next_url = self.client.extract_link_with_key(json_response, "Elements") - for item in self.recurse_next_item(next_url): - if limit.is_reached(): - break - yield item + if self.use_batch_mode: + for item in self.batch_next_item(json_response, type="Database"): + if limit.is_reached(): + break + yield item + else: + next_url = self.client.extract_link_with_key(json_response, "Elements") + for item in self.recurse_next_item(next_url): + if limit.is_reached(): + break + yield item def recurse_next_item(self, next_url, parent=None, type=None): logger.info("recurse_next_item") @@ -82,6 +90,147 @@ def recurse_next_item(self, next_url, parent=None, type=None): "Id": item.get("Id") } + def batch_next_item(self, next_item, parent=None, type=None): + todo_list = [] + todo_list.append( + { + "url": self.client.extract_link_with_key(next_item, "Elements"), + "parent": next_item.get("Name"), + "type": "Database" + } + ) + batch_requests_parameters= [] + while todo_list: + item = todo_list.pop() + request_kwargs = { + "url": item.get("url"), + "headers": self.client.get_requests_headers() + } + batch_requests_parameters.append(request_kwargs) + if not todo_list or len(batch_requests_parameters) > self.batch_size: + json_responses = self.client._batch_requests(batch_requests_parameters) + batch_requests_parameters = [] + for json_response in json_responses: + response_content = json_response.get("Content", {}) + links = response_content.get("Links", {}) + next_link = links.get("Next", {}) + # do something if there is a next link... + if next_link: + todo_list.append( + { + "url": next_link + } + ) + retrieved_items = response_content.get(OSIsoftConstants.API_ITEM_KEY, []) + for retrieved_item in retrieved_items: + retrieved_item_path = retrieved_item.get("Path") + elements_url = self.client.extract_link_with_key(retrieved_item, "Elements") + attributes_url = self.client.extract_link_with_key(retrieved_item, "Attributes") + if elements_url: + todo_list.append( + { + "url": elements_url, + "type": "Element", + "parent": retrieved_item_path + } + ) + if attributes_url: + todo_list.append( + { + "url": attributes_url, + "type": "Attribute", + "parent": retrieved_item_path + } + ) + yield { + "ItemType": type, + "Name": retrieved_item.get("Name"), + "Type": retrieved_item.get("Type"), + "Description": retrieved_item.get("Description"), + "Path": retrieved_item.get("Path"), + "Parent": parent, + "DefaultUnitsName": retrieved_item.get("DefaultUnitsName"), + "TemplateName": retrieved_item.get("TemplateName"), + "CategoryNames": retrieved_item.get("CategoryNames"), + "ExtendedProperties": retrieved_item.get("ExtendedProperties"), + "Step": retrieved_item.get("Step"), + "WebId": retrieved_item.get("WebId"), + "Id": retrieved_item.get("Id") + } + + + def batch_recurse_next_item(self, next_items, parents=None, type=None): + # logger.info("batch_recurse_next_item") + if not isinstance(next_items, list): + next_items = [next_items] + if not isinstance(parents, list): + parents = [parents] + batch_requests_parameters= [] + types = [] + items_parents_names = [] + for next_item in next_items: + next_item_name = next_item.get("Path") + next_elements_url = self.client.extract_link_with_key(next_item, "Elements") + if next_elements_url: + request_kwargs = { + "url": next_elements_url, + "headers": self.client.get_requests_headers() + } + batch_requests_parameters.append(request_kwargs) + types.append("Element") + items_parents_names.append(next_item_name) + next_attributes_url = self.client.extract_link_with_key(next_item, "Attributes") + if next_attributes_url: + request_kwargs = { + "url": next_attributes_url, + "headers": self.client.get_requests_headers() + } + batch_requests_parameters.append(request_kwargs) + types.append("Attribute") + items_parents_names.append(next_item_name) + if batch_requests_parameters: + json_responses = self.client._batch_requests(batch_requests_parameters) + # for json_response in json_responses: + # # Here we process recurse based on each response in the batch + # # Instead we could process all responses and batch all of them in one go... + # response_content = json_response.get("Content", {}) + # if OSIsoftConstants.DKU_ERROR_KEY in response_content: + # # Do something ? + # pass + # items = response_content.get(OSIsoftConstants.API_ITEM_KEY, []) + # batched_items = self.batch_recurse_next_item(items) + # for item in batched_items: + # yield item + # approach 2: + next_batch_items = [] + for json_response in json_responses: + response_content = json_response.get("Content", {}) + links = response_content.get("Links", {}) + next_link = links.get("Next", {}) + # do something if there is a next link... + items = response_content.get(OSIsoftConstants.API_ITEM_KEY, []) + next_batch_items.extend(items) + batched_items = self.batch_recurse_next_item(next_batch_items, parents=items_parents_names) + for item in batched_items: + yield item + + for item, parent in zip(next_items, parents): + yield { + "ItemType": type, + "Name": item.get("Name"), + "Type": item.get("Type"), + "Description": item.get("Description"), + "Path": item.get("Path"), + "Parent": parent, + "DefaultUnitsName": item.get("DefaultUnitsName"), + "TemplateName": item.get("TemplateName"), + "CategoryNames": item.get("CategoryNames"), + "ExtendedProperties": item.get("ExtendedProperties"), + "Step": item.get("Step"), + "WebId": item.get("WebId"), + "Id": item.get("Id") + } + def get_writer(self, dataset_schema=None, dataset_partitioning=None, partition_id=None, write_mode="OVERWRITE"): raise NotImplementedError From 11f9efa634678c8a26757ae95d114b110b55bf35 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 13 Nov 2025 10:38:51 +0100 Subject: [PATCH 017/156] preselect the batch mode --- python-connectors/pi-system_hierarchy/connector.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python-connectors/pi-system_hierarchy/connector.json b/python-connectors/pi-system_hierarchy/connector.json index 31754ee7..f66a4ed6 100644 --- a/python-connectors/pi-system_hierarchy/connector.json +++ b/python-connectors/pi-system_hierarchy/connector.json @@ -21,7 +21,7 @@ "label": " ", "type": "BOOLEAN", "description": "Show advanced parameters", - "defaultValue": false + "defaultValue": true }, { "name": "server_url", @@ -61,7 +61,7 @@ "type": "BOOLEAN", "description": "Use batch mode", "visibilityCondition": "model.show_advanced_parameters==true", - "defaultValue": false + "defaultValue": true }, { "name": "batch_size", From 9426f33b885c49e74e3cd1cbc33357a86daebf28 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 19 Nov 2025 13:14:45 +0100 Subject: [PATCH 018/156] fixing parent column --- .../pi-system_hierarchy/connector.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/python-connectors/pi-system_hierarchy/connector.py b/python-connectors/pi-system_hierarchy/connector.py index bcf49bdb..fac8d137 100644 --- a/python-connectors/pi-system_hierarchy/connector.py +++ b/python-connectors/pi-system_hierarchy/connector.py @@ -1,3 +1,4 @@ +import datetime from dataiku.connector import Connector from osisoft_client import OSIsoftClient from safe_logger import SafeLogger @@ -40,12 +41,14 @@ def get_read_schema(self): def generate_rows(self, dataset_schema=None, dataset_partitioning=None, partition_id=None, records_limit = -1): limit = RecordsLimit(records_limit) + start_time = datetime.datetime.now() headers = self.client.get_requests_headers() json_response = self.client.get(url=self.database_endpoint, headers=headers, params={}, error_source="traverse") + server_name = json_response.get("ExtendedProperties", {}).get("DefaultPIServer", {}).get("Value", "Unknown server name") if self.use_batch_mode: - for item in self.batch_next_item(json_response, type="Database"): + for item in self.batch_next_item(json_response, parent=server_name, type="Database"): if limit.is_reached(): break yield item @@ -55,6 +58,10 @@ def generate_rows(self, dataset_schema=None, dataset_partitioning=None, if limit.is_reached(): break yield item + end_time = datetime.datetime.now() + duration = end_time - start_time + logger.info("generate_rows overall duration = {}s".format(duration.microseconds/1000000 + duration.seconds)) + logger.info("Network timer:{}".format(self.network_timer.get_report())) def recurse_next_item(self, next_url, parent=None, type=None): logger.info("recurse_next_item") @@ -95,11 +102,12 @@ def batch_next_item(self, next_item, parent=None, type=None): todo_list.append( { "url": self.client.extract_link_with_key(next_item, "Elements"), - "parent": next_item.get("Name"), + "parent": "\\\\" + parent + "\\" + next_item.get("Name"), "type": "Database" } ) batch_requests_parameters= [] + parent_of_batched_items = [] while todo_list: item = todo_list.pop() request_kwargs = { @@ -107,10 +115,11 @@ def batch_next_item(self, next_item, parent=None, type=None): "headers": self.client.get_requests_headers() } batch_requests_parameters.append(request_kwargs) + parent_of_batched_items.append(item.get("parent")) if not todo_list or len(batch_requests_parameters) > self.batch_size: json_responses = self.client._batch_requests(batch_requests_parameters) batch_requests_parameters = [] - for json_response in json_responses: + for parent_of_batched_item, json_response in zip(parent_of_batched_items, json_responses): response_content = json_response.get("Content", {}) links = response_content.get("Links", {}) next_link = links.get("Next", {}) @@ -131,7 +140,7 @@ def batch_next_item(self, next_item, parent=None, type=None): { "url": elements_url, "type": "Element", - "parent": retrieved_item_path + "parent": parent_of_batched_item + "\\" + retrieved_item.get("Name") } ) if attributes_url: @@ -139,7 +148,7 @@ def batch_next_item(self, next_item, parent=None, type=None): { "url": attributes_url, "type": "Attribute", - "parent": retrieved_item_path + "parent": parent_of_batched_item + "\\" + retrieved_item.get("Name") } ) yield { @@ -148,7 +157,8 @@ def batch_next_item(self, next_item, parent=None, type=None): "Type": retrieved_item.get("Type"), "Description": retrieved_item.get("Description"), "Path": retrieved_item.get("Path"), - "Parent": parent, + "LinkPath": "{}\\{}".format(parent_of_batched_item, retrieved_item.get("Name")), + "Parent": parent_of_batched_item, "DefaultUnitsName": retrieved_item.get("DefaultUnitsName"), "TemplateName": retrieved_item.get("TemplateName"), "CategoryNames": retrieved_item.get("CategoryNames"), @@ -157,6 +167,7 @@ def batch_next_item(self, next_item, parent=None, type=None): "WebId": retrieved_item.get("WebId"), "Id": retrieved_item.get("Id") } + parent_of_batched_items = [] def batch_recurse_next_item(self, next_items, parents=None, type=None): From 45f12821ba48d41e33a4f72d19e192dce7e9da38 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 26 Nov 2025 09:39:54 +0100 Subject: [PATCH 019/156] v1.4.1 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 517456c3..27db1a9a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "pi-system", - "version": "1.4.0", + "version": "1.4.1", "meta": { "label": "PI System", "description": "Retrieve data from your OSIsoft PI System servers", From 0010b3fdee35013be2d5feb0095022ff2bf59754 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 26 Nov 2025 09:40:04 +0100 Subject: [PATCH 020/156] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc652ae..febc0de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Version 1.4.1](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.4.1) - Feature release - 2025-11-26 + +- Add a AF hierarchy downloader + ## [Version 1.4.0](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.4.0) - Feature release - 2025-09-22 - Add write recipe From 7f18196c5816bd05097a4e406c06a33ef792c905 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 26 Nov 2025 09:40:16 +0100 Subject: [PATCH 021/156] beta 1 --- python-lib/osisoft_constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-lib/osisoft_constants.py b/python-lib/osisoft_constants.py index 20c8efae..ce9b74ab 100644 --- a/python-lib/osisoft_constants.py +++ b/python-lib/osisoft_constants.py @@ -405,7 +405,7 @@ class OSIsoftConstants(object): "Security": "{base_url}/eventframes/{webid}/security", "SecurityEntries": "{base_url}/eventframes/{webid}/securityentries" } - PLUGIN_VERSION = "1.4.0-beta.1" + PLUGIN_VERSION = "1.4.1-beta.1" VALUE_COLUMN_SUFFIX = "_val" WEB_API_PATH = "piwebapi" WRITE_HEADERS = {'X-Requested-With': 'XmlHttpRequest'} From e178be9d78806858e7bc2e77df12114113f8adc4 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 3 Dec 2025 08:51:20 +0100 Subject: [PATCH 022/156] add tree explorer on recipe --- custom-recipes/pi-system-af-tree/recipe.json | 124 +++++++++++ custom-recipes/pi-system-af-tree/recipe.py | 212 +++++++++++++++++++ js/pi-system_treecontroller.js | 121 +++++++++++ resource/browse_af_tree.py | 80 +++++++ resource/pi-system_af-explorer.html | 47 ++++ 5 files changed, 584 insertions(+) create mode 100644 custom-recipes/pi-system-af-tree/recipe.json create mode 100644 custom-recipes/pi-system-af-tree/recipe.py create mode 100644 js/pi-system_treecontroller.js create mode 100644 resource/browse_af_tree.py create mode 100644 resource/pi-system_af-explorer.html diff --git a/custom-recipes/pi-system-af-tree/recipe.json b/custom-recipes/pi-system-af-tree/recipe.json new file mode 100644 index 00000000..5b917a43 --- /dev/null +++ b/custom-recipes/pi-system-af-tree/recipe.json @@ -0,0 +1,124 @@ +{ + "meta": { + "label": "AF tree explorer", + "description": "Explore the AF tree", + "icon": "icon-pi-system icon-cogs" + }, + "kind": "PYTHON", + "selectableFromDataset": "input_dataset", + "paramsPythonSetup": "browse_af_tree.py", + "inputRoles": [ + { + "name": "input_dataset", + "label": "Dataset containing paths or tags", + "description": "", + "arity": "UNARY", + "required": false, + "acceptsDataset": true + } + ], + + "outputRoles": [ + { + "name": "api_output", + "label": "Main output displayed name", + "description": "", + "arity": "UNARY", + "required": true, + "acceptsDataset": true + } + ], + "paramsTemplate" : "pi-system_af-explorer.html", + "paramsModule" : "piSystemTreeApp.module", + "params": [ + { + "type": "SEPARATOR", + "label": "Authentication" + }, + { + "name": "credentials", + "label": "User preset", + "type": "PRESET", + "parameterSetId": "basic-auth" + }, + { + "name": "show_advanced_parameters", + "label": "Show advanced parameters", + "type": "BOOLEAN", + "definition": "", + "defaultValue": false + }, + { + "name": "use_server_url_column", + "label": "Use server value per row", + "visibilityCondition": "model.show_advanced_parameters==true && false", + "description": "", + "type": "BOOLEAN", + "defaultValue": false + }, + { + "visibilityCondition": "(model.use_server_url_column==true) && (model.show_advanced_parameters==true)", + "name": "server_url_column", + "label": "Server domain columnn", + "description": "Should match the required path for each row", + "type": "COLUMN", + "columnRole": "input_dataset" + }, + { + "visibilityCondition": "(model.use_server_url_column==false) && (model.show_advanced_parameters==true)", + "name": "server_url", + "label": "Server URL", + "type": "STRING", + "definition": "https://my_server:8082", + "defaultValue": "" + }, + { + "name": "is_ssl_check_disabled", + "label": "Disable SSL check", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "BOOLEAN", + "definition": "", + "defaultValue": true + }, + { + "name": "ssl_cert_path", + "label": "Path to SSL certificate", + "type": "STRING", + "description": "(optional)", + "visibilityCondition": "model.show_advanced_parameters==true && model.is_ssl_check_disabled==false", + "mandatory": false + }, + { + "name": "must_convert_object_to_string", + "label": "Convert objects to string", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "BOOLEAN", + "description": "(for direct output to databases)", + "defaultValue": false + }, + { + "name": "is_debug_mode", + "label": "Verbose logging", + "visibilityCondition": "model.show_advanced_parameters==true", + "type": "BOOLEAN", + "description": "", + "defaultValue": false + }, + { + "name": "server_name", + "label": "Server name", + "type": "SELECT", + "description": "", + "getChoicesFromPython": true + }, + { + "name": "database_name", + "label": "Database name", + "type": "SELECT", + "description": "", + "visibilityCondition": "model.server_name.length>=0", + "getChoicesFromPython": true + } + ], + "resourceKeys": [] +} diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py new file mode 100644 index 00000000..c677ac2c --- /dev/null +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +import dataiku +from dataiku.customrecipe import get_input_names_for_role, get_recipe_config, get_output_names_for_role +import pandas as pd +from safe_logger import SafeLogger +from osisoft_plugin_common import ( + get_credentials, get_interpolated_parameters, normalize_af_path, + get_combined_description, get_base_for_data_type, check_debug_mode, + PerformanceTimer, get_max_count, check_must_convert_object_to_string, + convert_schema_objects_to_string, get_summary_parameters, get_advanced_parameters, + get_batch_parameters +) +from osisoft_client import OSIsoftClient +from osisoft_constants import OSIsoftConstants + + +logger = SafeLogger("pi-system plugin", forbiden_keys=["token", "password"]) + +logger.info("PIWebAPI Assets values downloader recipe v{}".format( + OSIsoftConstants.PLUGIN_VERSION +)) + + +def get_step_value(item): + if item and "Step" in item: + if item.get("Step") is True: + return "True" + else: + return "False" + return None + + +input_dataset = get_input_names_for_role('input_dataset') +output_names_stats = get_output_names_for_role('api_output') +config = get_recipe_config() +print("ALX:config={}".format(config)) +dku_flow_variables = dataiku.get_flow_variables() + +logger.info("Initialization with config config={}".format(logger.filter_secrets(config))) + +auth_type, username, password, server_url, is_ssl_check_disabled = get_credentials(config) +is_debug_mode = check_debug_mode(config) +max_count = get_max_count(config) +summary_type = config.get("summary_type") +must_convert_object_to_string = check_must_convert_object_to_string(config) + +use_server_url_column = config.get("use_server_url_column", False) +if not server_url and not use_server_url_column: + raise ValueError("Server domain not set") + +path_column = config.get("path_column", "") +if not path_column: + raise ValueError("There is no parameter column selected.") + +data_type = config.get("data_type") +start_time = config.get("start_time") +end_time = config.get("end_time") +use_start_time_column = config.get("use_start_time_column", False) +start_time_column = config.get("start_time_column") +use_end_time_column = config.get("use_end_time_column", False) +end_time_column = config.get("end_time_column") +server_url_column = config.get("server_url_column") +use_batch_mode, batch_size = get_advanced_parameters(config) +interval, sync_time, boundary_type = get_interpolated_parameters(config) +record_boundary_type = config.get("record_boundary_type") if data_type == "RecordedData" else None +summary_type, summary_duration = get_summary_parameters(config) +do_duplicate_input_row = config.get("do_duplicate_input_row", False) +max_request_size, estimated_density, maximum_points_returned = get_batch_parameters(config) +max_time_to_retrieve_per_batch = estimated_density / maximum_points_returned #density per hour <- max time is in hour + +network_timer = PerformanceTimer() +processing_timer = PerformanceTimer() +processing_timer.start() + +input_parameters_dataset = dataiku.Dataset(input_dataset[0]) +output_dataset = dataiku.Dataset(output_names_stats[0]) +input_parameters_dataframe = input_parameters_dataset.get_dataframe() + +results = [] +time_last_request = None +client = None +previous_server_url = "" +time_not_parsed = True + +input_columns = list(input_parameters_dataframe.columns) if do_duplicate_input_row else [] + +with output_dataset.get_writer() as writer: + first_dataframe = True + absolute_index = 0 + batch_buffer_size = 0 + buffer = [] + for index, input_parameters_row in input_parameters_dataframe.iterrows(): + absolute_index += 1 + server_url = input_parameters_row.get(server_url_column, server_url) if use_server_url_column else server_url + start_time = input_parameters_row.get(start_time_column, start_time) if use_start_time_column else start_time + end_time = input_parameters_row.get(end_time_column, end_time) if use_end_time_column else end_time + row_name = input_parameters_row.get("Name") + duplicate_initial_row = {} + nb_rows_to_process = input_parameters_dataframe.shape[0] + for input_column in input_columns: + duplicate_initial_row[input_column] = input_parameters_row.get(input_column) + + if client is None or previous_server_url != server_url: + client = OSIsoftClient( + server_url, auth_type, username, password, + is_ssl_check_disabled=is_ssl_check_disabled, + is_debug_mode=is_debug_mode, network_timer=network_timer + ) + previous_server_url = server_url + if time_not_parsed: + # make sure all OSIsoft time string format are evaluated at the same time + # rather than at every request, at least for start / end times set in the UI + time_not_parsed = False + start_time = client.parse_pi_time(start_time) + end_time = client.parse_pi_time(end_time) + sync_time = client.parse_pi_time(sync_time) + + object_id = input_parameters_row.get(path_column) + item = None + if client.is_resource_path(object_id): + object_id = normalize_af_path(object_id) + item = client.get_item_from_path(object_id) + step_value = get_step_value(item) + if item: + rows = client.recursive_get_rows_from_item( + item, + data_type, + start_date=start_time, + end_date=end_time, + interval=interval, + sync_time=sync_time, + boundary_type=boundary_type, + record_boundary_type=record_boundary_type, + max_count=max_count, + can_raise=False, + object_id=object_id, + summary_type=summary_type, + summary_duration=summary_duration + ) + elif use_batch_mode: + buffer.append({"WebId": object_id}) + batch_buffer_size += 1 + if (batch_buffer_size >= batch_size) or (absolute_index == nb_rows_to_process): + rows = client.get_rows_from_webids( + buffer, data_type, max_count=max_count, + start_date=start_time, + end_date=end_time, + interval=interval, + sync_time=sync_time, + boundary_type=boundary_type, + record_boundary_type=record_boundary_type, + can_raise=False, + batch_size=batch_size, + object_id=object_id, + summary_type=summary_type, + summary_duration=summary_duration, + endpoint_type="AF", + estimated_density=estimated_density, + maximum_points_returned=maximum_points_returned + ) + batch_buffer_size = 0 + buffer = [] + else: + continue + else: + rows = client.recursive_get_rows_from_webid( + object_id, + data_type, + start_date=start_time, + end_date=end_time, + interval=interval, + sync_time=sync_time, + boundary_type=boundary_type, + record_boundary_type=record_boundary_type, + max_count=max_count, + can_raise=False, + endpoint_type="AF", + summary_type=summary_type, + summary_duration=summary_duration + ) + for row in rows: + row["Name"] = row_name + row[path_column] = object_id + if isinstance(row, list): + for line in row: + base = get_base_for_data_type(data_type, object_id, Step=step_value) + base.update(line) + extention = client.unnest_row(base) + results.extend(extention) + else: + base = get_base_for_data_type(data_type, object_id, Step=step_value) + if duplicate_initial_row: + base.update(duplicate_initial_row) + base.update(row) + extention = client.unnest_row(base) + results.extend(extention) + + unnested_items_rows = pd.DataFrame(results) + if first_dataframe: + default_columns = OSIsoftConstants.RECIPE_SCHEMA_PER_DATA_TYPE.get(data_type) + if must_convert_object_to_string: + default_columns = convert_schema_objects_to_string(default_columns) + combined_columns_description = get_combined_description(default_columns, unnested_items_rows) + output_dataset.write_schema(combined_columns_description) + first_dataframe = False + if not unnested_items_rows.empty: + writer.write_dataframe(unnested_items_rows) + results = [] + +processing_timer.stop() +logger.info("Overall timer:{}".format(processing_timer.get_report())) +logger.info("Network timer:{}".format(network_timer.get_report())) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js new file mode 100644 index 00000000..93739ddf --- /dev/null +++ b/js/pi-system_treecontroller.js @@ -0,0 +1,121 @@ +var app = angular.module('piSystemTreeApp.module', []); + +app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', function($scope, $http, CreateModalFromTemplate) { + $scope.treeData = []; + $http.get('/plugins/treeview/resource/tree.json') + .then(function(response) { + $scope.treeData = response.data; + console.log($scope.treeData); + }) + + // Toggle récursif des checkboxes + $scope.toggleChildren = function(node) { + console.log("ALX:" + JSON.stringify(node)); + if (node.children && node.children.length) { + node.children.forEach(function(child) { + child.checked = node.checked; + $scope.toggleChildren(child); + + }); + } + }; + $scope.testPythonDo = function(noken) { + console.log("ALX:" + JSON.stringify(noken)); + console.log("ALX:config=" + JSON.stringify($scope.config)) + $scope.callPythonDo({method: "get_query_catalogs"}).then(function(data){ + console.log("ALX:testPythonDo return:"+JSON.stringify(data)) + }); + }; + +}]); + +app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMirrorSettingService) { + $scope.paramDesc = { + 'parameterSetId': 'basic-auth', + 'mandatory': true + }; + + $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); + + $scope.init = function() { + DataikuAPI.plugins.listAccessiblePresets('pi-system', $stateParams.projectKey, 'basic-auth').success(function (data) { + $scope.inlineParams = data.inlineParams; + $scope.inlinePluginParams = data.inlinePluginParams; + $scope.accessiblePresets = []; + if (data.definableInline) { + $scope.accessiblePresets.push({ + name:"INLINE", + label:"Manually defined", usable:true, + description: "Define values for these parameters" + }); + } + data.presets.forEach(function(p) { + $scope.accessiblePresets.push({name:"PRESET " + p.name, label:p.name, usable:p.usable, description:p.description}); + }); + $scope.accessibleParameterSetDescriptions = $scope.accessiblePresets.map(function(p) { + return p.description || 'No description'; + }); + }).error(setErrorInScope.bind($scope.errorScope)); + }; + + $scope.getServers = function(noken){ + console.log("ALX:get servers"); + console.log("ALX:" + JSON.stringify(noken)); + $scope.callPythonDo({parameterName: "server_name"}).then(function(data){ + console.log("ALX:getServers return:"+JSON.stringify(data)) + // $scope.config["server_name"] = data.choices; + $scope.server_name = data.choices; + }); + }; + $scope.getDatabases = function() { + $scope.callPythonDo({parameterName: "database_name"}).then(function(data){ + console.log("ALX:getDatabases return:"+JSON.stringify(data)) + $scope.database_name = data.choices; + }); + }; + +}); + +app.directive('treeNode', function() { + return { + restrict: 'E', + scope: { node: '=' }, + template: ` +
+ + + + + + + + + + + +
+ + +
    +
  • + +
  • +
+ `, + link: function(scope) { + // Récupère la fonction toggleChildren du parent + scope.toggleChildren = scope.$parent.toggleChildren; + + // Simple toggle du expand (plus de chargement HTTP) + scope.toggleExpand = function(node) { + console.log("ALX:expand !" + JSON.stringify(node)); + node.expanded = !node.expanded; + }; + } + }; +}); diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py new file mode 100644 index 00000000..51f82ac4 --- /dev/null +++ b/resource/browse_af_tree.py @@ -0,0 +1,80 @@ +from osisoft_client import OSIsoftClient +from osisoft_plugin_common import get_credentials, build_select_choices, check_debug_mode + + +def do(payload, config, plugin_config, inputs): + config["is_ssl_check_disabled"] = True + print("ALX:af explorer do, payload={}, config={}, plugin_config={}, inputs={}".format(payload, config, plugin_config, inputs)) + if "config" in config: + config = config.get("config") + if "credentials" not in config: + return {"choices": [{"label": "Requires DSS v10.0.4 or above. Please use the OSIsoft Search custom dataset instead"}]} + elif config.get("credentials") == {}: + return {"choices": [{"label": "Pick a credential"}]} + + auth_type, username, password, server_url, is_ssl_check_disabled, credential_error = get_credentials(config, can_raise=False) + + if credential_error: + return build_select_choices(credential_error) + + if not (auth_type and username and password): + return build_select_choices("Pick a credential") + + if not username or not password: + return build_select_choices( + "Incorrect credential. " + + "Go to you profile page > Credentials > Your preset, click the edit button and fill in you username and password details." + ) + + if not server_url: + return build_select_choices("Fill in the server address") + + is_debug_mode = check_debug_mode(config) + is_ssl_check_disabled = True + print("ALX:is_ssl_check_disabled={}".format(is_ssl_check_disabled)) + + client = OSIsoftClient(server_url, auth_type, username, password, is_ssl_check_disabled=is_ssl_check_disabled, is_debug_mode=is_debug_mode) + print("ALX:2") + + method = payload.get("method") + if method == "get_query_catalogs": + return get_query_catalogs(None, config) + + parameter_name = payload.get("parameterName") + + if parameter_name == "server_name": + choices = [] + print("ALX:do function") + servers = client.get_asset_servers(can_raise=False) + print("ALX:servers={}".format(servers)) + choices.extend(servers) + print("ALX:server choices={}".format(choices)) + return build_select_choices(choices) + + if parameter_name == "data_server_url": + choices = [] + choices.extend(client.get_data_servers(can_raise=False)) + return build_select_choices(choices) + + if parameter_name == "database_name": + choices = [] + next_url = config.get("server_name") + if next_url: + choices.extend(client.get_next_choices(next_url, "Self")) + return build_select_choices(choices) + else: + return build_select_choices() + + return build_select_choices() + + +def get_query_catalogs(cnx, config): + print("ALX:def get_query_catalogs") + print("ALX:cnx={}, config={}".format(cnx, config)) + user = config.get("credentials", {}).get("osisoft_basic", {}).get("user") + password = config.get("credentials", {}).get("osisoft_basic", {}).get("password") + return {"choices": [user, password]} + + +def get_children(username, password, source_item_url): + pass diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html new file mode 100644 index 00000000..def5cb40 --- /dev/null +++ b/resource/pi-system_af-explorer.html @@ -0,0 +1,47 @@ +
+
+ +
+
+
+
+
+
+ {{config.server_name | json}} +
+ + +
+
+
+ {{config.database_name | json}} +
+ + +
+
+
+ +
+ +
    +
  • + +
  • +
+
From e2efc48cf0568116449eeef02ccbb6abf7fbccbd Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 11 Dec 2025 11:35:57 +0100 Subject: [PATCH 023/156] first working tree --- js/pi-system_treecontroller.js | 24 ++++++++++++--- python-lib/osisoft_client.py | 23 ++++++++++++++ python-lib/osisoft_plugin_common.py | 2 +- resource/browse_af_tree.py | 47 +++++++++++++++++++++++++++++ resource/pi-system_af-explorer.html | 1 + resource/tree.json | 20 ++++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 resource/tree.json diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 93739ddf..43b6bf63 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -2,7 +2,7 @@ var app = angular.module('piSystemTreeApp.module', []); app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', function($scope, $http, CreateModalFromTemplate) { $scope.treeData = []; - $http.get('/plugins/treeview/resource/tree.json') + $http.get('/plugins/pi-system/resource/tree.json') .then(function(response) { $scope.treeData = response.data; console.log($scope.treeData); @@ -10,7 +10,7 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio // Toggle récursif des checkboxes $scope.toggleChildren = function(node) { - console.log("ALX:" + JSON.stringify(node)); + console.log("ALX:tc:" + JSON.stringify(node)); if (node.children && node.children.length) { node.children.forEach(function(child) { child.checked = node.checked; @@ -20,12 +20,19 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio } }; $scope.testPythonDo = function(noken) { - console.log("ALX:" + JSON.stringify(noken)); + console.log("ALX:tpd:" + JSON.stringify(noken)); console.log("ALX:config=" + JSON.stringify($scope.config)) $scope.callPythonDo({method: "get_query_catalogs"}).then(function(data){ console.log("ALX:testPythonDo return:"+JSON.stringify(data)) }); }; + $scope.getChildrenFromDB = function(item){ + console.log("ALX:gcfd:" + JSON.stringify(item)); + $scope.callPythonDo({method: "get_children_from_db", parent: item}).then(function(data){ + console.log("ALX:data1=" + JSON.stringify(data)); + item["children"] = data["choices"] + }); + } }]); @@ -73,6 +80,12 @@ app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMi $scope.database_name = data.choices; }); }; + $scope.initializeTree = function(){ + console.log("ALX:initializeTree:scope=" + JSON.stringify($scope.config.database_name)); + $scope.callPythonDo({method: "get_children_from_db", parent: $scope.config.database_name}).then(function(data){ + console.log("ALX:data2=" + JSON.stringify(data)); + }); + }; }); @@ -84,7 +97,7 @@ app.directive('treeNode', function() {
- @@ -110,11 +123,12 @@ app.directive('treeNode', function() { link: function(scope) { // Récupère la fonction toggleChildren du parent scope.toggleChildren = scope.$parent.toggleChildren; - + scope.getChildrenFromDB = scope.$parent.getChildrenFromDB; // Simple toggle du expand (plus de chargement HTTP) scope.toggleExpand = function(node) { console.log("ALX:expand !" + JSON.stringify(node)); node.expanded = !node.expanded; + scope.getChildrenFromDB(node); }; } }; diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 2bb09778..e80bf870 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -491,6 +491,29 @@ def get_item_from_url(self, url): ) return json_response + def get_next_item_from_url(self, url): + headers = self.get_requests_headers() + params = {} + while url: + json_response = self.get( + url=url, + headers=headers, + params=params, + can_raise=False, + error_source="get_next_item_from_url" + ) + url = get_next_page_url(json_response) + print("ALX:new url={}".format(url)) + if isinstance(json_response, list): + for item in json_response: + yield item + elif "Items" in json_response: + items = json_response.get("Items", []) + for item in items: + yield item + else: + yield json_response + def get(self, url, headers, params, can_raise=True, error_source=None): error_message = None url = build_query_string(url, params) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index c8e08155..6f8b8edd 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -496,7 +496,7 @@ def fields_selector(data_type): def get_next_page_url(json): - if not json: + if not isinstance(json, dict): return None next_page_url = json.get("Links", {}).get("Next", "").replace('&', '&') if next_page_url: diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 51f82ac4..1b4d79a5 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -39,6 +39,9 @@ def do(payload, config, plugin_config, inputs): method = payload.get("method") if method == "get_query_catalogs": return get_query_catalogs(None, config) + if method == "get_children_from_db": + parent = payload.get("parent", {}) + return get_children_from_db(client, parent) parameter_name = payload.get("parameterName") @@ -78,3 +81,47 @@ def get_query_catalogs(cnx, config): def get_children(username, password, source_item_url): pass + + +def get_children_from_db(client, parent_node): + print("ALX:parent_node={}".format(parent_node)) + # ALX:parent_node={'show_advanced_parameters': False, 'use_server_url_column': False, 'is_ssl_check_disabled': True, 'must_convert_object_to_string': False, 'is_debug_mode': False, 'credentials': {'auth_type': 'basic', 'can_disable_ssl_check': True, 'ssl_cert_path': '', 'default_server': 'dku-qa-osi.francecentral.cloudapp.azure.com', 'can_override_server_url': True, 'get_parameters': {}, 'post_parameters': {}, 'url_swap': [], 'max_request_size': 1000, 'estimated_density': 6, 'maximum_points_returned': 600, 'osisoft_basic': {'user': 'abourret', 'password': 'S58BirZjtsUDTJ3'}}} + if isinstance(parent_node, dict): + url = parent_node.get("url") + else: + url = parent_node + print("ALX:url to search:{}".format(url)) + this_node = next(client.get_next_item_from_url(url)) + links = this_node.get("Links", {}) + attributes_url = links.get("Attributes") + elements_url = links.get("Elements") + children = [] + if attributes_url: + attributes = client.get_next_item_from_url(attributes_url) + for attribute in attributes: + child = get_item_details(attribute) + child["type"] = "attribute" + child["children"] = [] + children.append(child) + if elements_url: + elements = client.get_next_item_from_url(elements_url) + for element in elements: + child = get_item_details(element) + child["type"] = "element" + child["children"] = [] + children.append(child) + + return {"choices": children} + + +def get_item_details(item): + KEYS_TO_CHECK = {"Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", "HasChildren": "has_children", "Path": "path", "WebId": "id"} + details = {} + print("ALX:in:{}".format(item)) + for key_to_check in KEYS_TO_CHECK: + value = item.get(key_to_check) + if value: + details[KEYS_TO_CHECK.get(key_to_check)] = value + print("ALX:out:{}".format(details)) + details["url"] = item.get("Links", {}).get("Self") + return details diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index def5cb40..9694c8f2 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -30,6 +30,7 @@
diff --git a/resource/tree.json b/resource/tree.json new file mode 100644 index 00000000..149ec9c2 --- /dev/null +++ b/resource/tree.json @@ -0,0 +1,20 @@ +[ + { + "id": 1, + "title": "Root", + "url": "https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw", + "expanded": false, + "checked": false, + "children": [ + { + "id": "blabla", + "url": "xcvb", + "title": "Well", + "expanded": false, + "checked": false, + "children": [ + ] + } + ] + } +] From 449ce974377ecb9b74731d69dad1d4c801215eb5 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Thu, 11 Dec 2025 16:02:27 +0100 Subject: [PATCH 024/156] charge servers and databases on the fly --- resource/pi-system_af-explorer.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 9694c8f2..1b6b029f 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -15,25 +15,26 @@
-
+
{{config.server_name | json}}
-
-
+
{{config.database_name | json}}
-
From 2859c43eab76711d514fb8b4615e3d2360733266 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Fri, 12 Dec 2025 12:03:01 +0100 Subject: [PATCH 025/156] check children when parent node is checked --- js/pi-system_treecontroller.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 43b6bf63..5cf78ed2 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -15,7 +15,6 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio node.children.forEach(function(child) { child.checked = node.checked; $scope.toggleChildren(child); - }); } }; @@ -28,12 +27,16 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio }; $scope.getChildrenFromDB = function(item){ console.log("ALX:gcfd:" + JSON.stringify(item)); - $scope.callPythonDo({method: "get_children_from_db", parent: item}).then(function(data){ - console.log("ALX:data1=" + JSON.stringify(data)); - item["children"] = data["choices"] - }); - } - + $scope.callPythonDo({ method: "get_children_from_db", parent: item }) + .then(function (data) { + console.log("ALX:data1=" + JSON.stringify(data)); + item.children = data.choices; + item.children.forEach(child => { + child.checked = item.checked; + child.expanded = false; + }); + }); + } }]); app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMirrorSettingService) { From cfb1815482d7f9da55463168b987b20a641768ac Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 15 Dec 2025 15:48:16 +0100 Subject: [PATCH 026/156] cleaning some tests in the UI --- js/pi-system_treecontroller.js | 10 ++-------- python-lib/osisoft_client.py | 8 ++++++-- resource/browse_af_tree.py | 24 ++++++++++++++---------- resource/pi-system_af-explorer.html | 5 ++--- resource/tree.json | 3 +-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 5cf78ed2..3f9e8673 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -18,13 +18,7 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio }); } }; - $scope.testPythonDo = function(noken) { - console.log("ALX:tpd:" + JSON.stringify(noken)); - console.log("ALX:config=" + JSON.stringify($scope.config)) - $scope.callPythonDo({method: "get_query_catalogs"}).then(function(data){ - console.log("ALX:testPythonDo return:"+JSON.stringify(data)) - }); - }; + $scope.getChildrenFromDB = function(item){ console.log("ALX:gcfd:" + JSON.stringify(item)); $scope.callPythonDo({ method: "get_children_from_db", parent: item }) @@ -108,7 +102,7 @@ app.directive('treeNode', function() { - +
- {{config.server_name | json}} +
-
  • diff --git a/resource/tree.json b/resource/tree.json index 149ec9c2..4c09976f 100644 --- a/resource/tree.json +++ b/resource/tree.json @@ -1,8 +1,7 @@ [ { "id": 1, - "title": "Root", - "url": "https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw", + "title": "Elements", "expanded": false, "checked": false, "children": [ From 99df49c2fd49c613166614883a1d2960be7bafdb Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 15 Dec 2025 17:49:28 +0100 Subject: [PATCH 027/156] sketch of a search function --- js/pi-system_treecontroller.js | 32 +++++++++++++++++++++++++++++ resource/browse_af_tree.py | 27 +++++++++++++++++------- resource/pi-system_af-explorer.html | 7 ++++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 3f9e8673..9d73b1b0 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -83,6 +83,38 @@ app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMi console.log("ALX:data2=" + JSON.stringify(data)); }); }; + $scope.doSearch = function(element_name, attribute_name){ + console.log("ALX:search for ", element_name, attribute_name); + $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name}).then( + function(data){ + console.log("ALX:search result:", JSON.stringify(data)); + // $scope.treeData = data.choices; + $scope.$parent.treeData = [ + { + "id": 1, + "title": "Elements", + "expanded": false, + "checked": false, + "children": [ + { + "id": "blabla", + "url": "xcvb", + "title": "Well", + "expanded": false, + "checked": false, + "children": [ + ] + } + ] + } + ]; + // item.children.forEach(child => { + // child.checked = item.checked; + // child.expanded = false; + // }); + } + ); + }; }); diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 4f521904..929a9b7f 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -52,6 +52,18 @@ def do(payload, config, plugin_config, inputs): database_name = config.get("database_name") parent = payload.get("parent", {}) return get_children_from_db(client, parent, database_name=database_name) + if method == "do_search": + database_name = config.get("database_name") + element_name = config.get("element_name") + attribute_name = config.get("attribute_name") + attributes = [] + # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw + database_webid = database_name.split("/")[-1] + for attribute in client.search_attributes( + database_webid, attribute_name=attribute_name, element_name=element_name): + print("ALX:attribute={}".format(attribute)) + attributes.append(attribute) + return {"choices": attributes} parameter_name = payload.get("parameterName") @@ -102,13 +114,6 @@ def get_children_from_db(client, parent_node, database_name=None): attributes_url = links.get("Attributes") elements_url = links.get("Elements") children = [] - if attributes_url: - attributes = client.get_next_item_from_url(attributes_url) - for attribute in attributes: - child = get_item_details(attribute) - child["type"] = "attribute" - child["children"] = [] - children.append(child) if elements_url: elements = client.get_next_item_from_url(elements_url) for element in elements: @@ -116,6 +121,14 @@ def get_children_from_db(client, parent_node, database_name=None): child["type"] = "element" child["children"] = [] children.append(child) + if attributes_url: + attributes = client.get_next_item_from_url(attributes_url) + for attribute in attributes: + child = get_item_details(attribute) + child["type"] = "attribute" + if child.get("has_children"): + child["children"] = [] + children.append(child) return {"choices": children} diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 73118c61..6d67d206 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -37,6 +37,11 @@
+ + +
@@ -45,4 +50,4 @@ -
+{{treeData | json}} From 0bf75304ce2ff641c75c257e5b6557d7ac12d7a2 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 18 Dec 2025 14:54:27 +0100 Subject: [PATCH 028/156] Adding search option --- js/pi-system_treecontroller.js | 54 ++++----------- python-lib/osisoft_client.py | 55 +++++++++++++++ python-lib/osisoft_plugin_common.py | 102 ++++++++++++++++++++++++++++ resource/browse_af_tree.py | 65 ++++++++++++++---- resource/pi-system_af-explorer.html | 10 +-- 5 files changed, 230 insertions(+), 56 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 9d73b1b0..ab70d03f 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -8,6 +8,10 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio console.log($scope.treeData); }) + $scope.refreshTree = function(newTree) { + $scope.treeData = newTree.choices + } + // Toggle récursif des checkboxes $scope.toggleChildren = function(node) { console.log("ALX:tc:" + JSON.stringify(node)); @@ -30,7 +34,16 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio child.expanded = false; }); }); - } + }; + + $scope.doSearch = function(element_name, attribute_name){ + console.log("ALX:search for ", element_name, attribute_name); + $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name}).then( + function(data){ + $scope.treeData = data.choices; + } + ); + }; }]); app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMirrorSettingService) { @@ -62,59 +75,22 @@ app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMi }).error(setErrorInScope.bind($scope.errorScope)); }; - $scope.getServers = function(noken){ - console.log("ALX:get servers"); - console.log("ALX:" + JSON.stringify(noken)); + $scope.getServers = function(){ $scope.callPythonDo({parameterName: "server_name"}).then(function(data){ - console.log("ALX:getServers return:"+JSON.stringify(data)) // $scope.config["server_name"] = data.choices; $scope.server_name = data.choices; }); }; $scope.getDatabases = function() { $scope.callPythonDo({parameterName: "database_name"}).then(function(data){ - console.log("ALX:getDatabases return:"+JSON.stringify(data)) $scope.database_name = data.choices; }); }; $scope.initializeTree = function(){ - console.log("ALX:initializeTree:scope=" + JSON.stringify($scope.config.database_name)); $scope.callPythonDo({method: "get_children_from_db", parent: $scope.config.database_name}).then(function(data){ console.log("ALX:data2=" + JSON.stringify(data)); }); }; - $scope.doSearch = function(element_name, attribute_name){ - console.log("ALX:search for ", element_name, attribute_name); - $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name}).then( - function(data){ - console.log("ALX:search result:", JSON.stringify(data)); - // $scope.treeData = data.choices; - $scope.$parent.treeData = [ - { - "id": 1, - "title": "Elements", - "expanded": false, - "checked": false, - "children": [ - { - "id": "blabla", - "url": "xcvb", - "title": "Well", - "expanded": false, - "checked": false, - "children": [ - ] - } - ] - } - ]; - // item.children.forEach(child => { - // child.checked = item.checked; - // child.expanded = false; - // }); - } - ); - }; }); diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 955e4118..493b8bc4 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -13,6 +13,7 @@ iso_to_epoch, RecordsLimit, is_iso8601, get_next_page_url, change_key_in_dict, BatchTimeCounter ) +from osisoft_plugin_common import get_item_details from osisoft_pagination import OffsetPagination from safe_logger import SafeLogger @@ -810,6 +811,60 @@ def traverse(self, path_elements): json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") if attribute: item = self.extract_item_with_name(json_response, attribute) + return item + + def traverse_and_cache(self, path_elements, path_attributes, tree): + print("ALX:traverse_and_cache:path_elements={}, path_attributes={}".format(path_elements, path_attributes)) + full_path_elements = path_elements.copy() + path_attributes.copy() + # Loading piwebapi initial page + next_url = self.endpoint.get_base_url() + headers = self.get_requests_headers() + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + + # Asset server page + next_url = self.extract_link_with_key(json_response, "AssetServers") + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + + item = self.extract_item_with_name(json_response, path_elements.pop(0)) + tree.put(full_path_elements[0:1], get_item_details(item)) + next_url = self.extract_link_with_key(item, "Databases") + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + + # retrieved_from_cache = tree.get(full_path_elements[0:2], {}).get("url")+"/elements" + # get the database + item = self.extract_item_with_name(json_response, path_elements.pop(0)) + tree.put(full_path_elements[0:2], get_item_details(item)) + next_url = self.extract_link_with_key(item, "Elements") + # print("ALX:database:next_url={}, retrieved_from_cache={}".format(next_url, retrieved_from_cache)) + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + + # Looping through elements + counter = 3 + before_last_url = None + for path_element in path_elements: + element, attribute = self.split_element_attribute(path_element) + item = self.extract_item_with_name(json_response, element) + tree.put(full_path_elements[0:counter], get_item_details(item)) + counter += 1 + before_last_url = self.extract_link_with_key(item, "Attributes") + if attribute: + next_url = self.extract_link_with_key(item, "Attributes") + else: + next_url = self.extract_link_with_key(item, "Elements") + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + if attribute: + item = self.extract_item_with_name(json_response, attribute) + json_response = self.get(url=before_last_url, headers=headers, params={}, error_source="traverse") + for path_attribute in path_attributes: + # print("ALX:extract '{}' from {}".format(path_attribute, json_response)) + item = self.extract_item_with_name(json_response, path_attribute) + tree.put(full_path_elements[0:counter], get_item_details(item)) + counter += 1 + next_url = self.extract_link_with_key(item, "Attributes") + if next_url: + json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse") + else: + break return item diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 6f8b8edd..4e2bd518 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -634,3 +634,105 @@ def is_batch_full(self): def add(self, start_time, end_time, interval): self.total_batched_time += compute_time_spent(start_time, end_time, interval) + + +def get_item_details(item): + KEYS_TO_CHECK = { + "Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", + "HasChildren": "has_children", "Path": "path", "WebId": "id" + } + details = {} + for key_to_check in KEYS_TO_CHECK: + value = item.get(key_to_check) + if value: + details[KEYS_TO_CHECK.get(key_to_check)] = value + details["url"] = item.get("Links", {}).get("Self") + return details + + +class Tree(): + # Each put + # - stores the data in the index + # - builds a tree based on the data's path, pointing at the right index + def __init__(self): + self.tree = {} + self.index = [] + + def put(self, path, data): + if isinstance(path, list): + current_level = self.tree + for token in path: + if token not in current_level: + current_level[token] = {} + current_level = current_level.get(token) + index_to_update = current_level.get("_v", None) + if index_to_update is not None: + self.index[index_to_update] = data + else: + last_index = len(self.index) + self.index.append(data) + current_level.update({"_v": last_index}) + + def get(self, path, default=None): + if isinstance(path, list): + current_level = self.tree + for token in path: + if token not in current_level: + return default + else: + current_level = current_level.get(token) + index = current_level.get("_v") + return self.get_record(index) + + def get_tree(self): + return self.tree + + def get_record(self, index): + if index < len(self.index): + return self.index[index] + return None + + def get_records(self): + return self.index + + def exists(self, path): + current = self.tree + if isinstance(path, list): + for token in path: + current = current.get(token, {}) + if not current: + return False + return True + return False + + def print(self): + print("Tree {}".format(self.tree)) + print("Tree content {}".format(self.index)) + + +def recursive_tree_rebuild(dictionary, records, counter=None): + counter = counter or -1 + output = [] + + for key in dictionary: + if key == "_v": + continue + sub_dictionary = dictionary.get(key) + context = {} + if "_v" in sub_dictionary: + index_id = sub_dictionary.get("_v") + if isinstance(index_id, int): + context = records[index_id] + counter += 1 + if sub_dictionary: + counter += 1 + children = recursive_tree_rebuild(sub_dictionary, records, counter + 1) + else: + children = [] + context["id"] = str(counter) + context["title"] = key + context["expanded"] = True + context["checked"] = False + context["children"] = children + output.append(context) + return output diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 929a9b7f..2fb8fef6 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -1,5 +1,6 @@ from osisoft_client import OSIsoftClient from osisoft_plugin_common import get_credentials, build_select_choices, check_debug_mode +from osisoft_plugin_common import get_item_details, Tree, recursive_tree_rebuild import dataiku @@ -61,9 +62,10 @@ def do(payload, config, plugin_config, inputs): database_webid = database_name.split("/")[-1] for attribute in client.search_attributes( database_webid, attribute_name=attribute_name, element_name=element_name): - print("ALX:attribute={}".format(attribute)) + # print("ALX:attribute={}".format(attribute)) attributes.append(attribute) - return {"choices": attributes} + rebuilt_tree = rebuild_tree(client, attributes) + return {"choices": rebuilt_tree} parameter_name = payload.get("parameterName") @@ -118,6 +120,7 @@ def get_children_from_db(client, parent_node, database_name=None): elements = client.get_next_item_from_url(elements_url) for element in elements: child = get_item_details(element) + # child["title"] = "🧩{}".format(child.get("title")) child["type"] = "element" child["children"] = [] children.append(child) @@ -125,6 +128,7 @@ def get_children_from_db(client, parent_node, database_name=None): attributes = client.get_next_item_from_url(attributes_url) for attribute in attributes: child = get_item_details(attribute) + # child["title"] = "🏷️{}".format(child.get("title")) child["type"] = "attribute" if child.get("has_children"): child["children"] = [] @@ -132,13 +136,50 @@ def get_children_from_db(client, parent_node, database_name=None): return {"choices": children} - -def get_item_details(item): - KEYS_TO_CHECK = {"Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", "HasChildren": "has_children", "Path": "path", "WebId": "id"} - details = {} - for key_to_check in KEYS_TO_CHECK: - value = item.get(key_to_check) - if value: - details[KEYS_TO_CHECK.get(key_to_check)] = value - details["url"] = item.get("Links", {}).get("Self") - return details +# method2: +# we dig, but this time it's index[token name], and we store as we go in the child, with the real data indexed in a list and just the rank pointing to it +# to build the final tree, we browse the index, get the index data, rebuild the struct from there +# Tree class ? put(path, data), get(path, data) + + +def rebuild_tree(client, items): + # builds an active tree containing all the items and their parent up to the root + # print("ALX:items={}".format(len(items))) + tree = Tree() + final_tree = [] + while len(items) > 1: + # print("ALX:items={}".format(items)) + item = items.pop() + if item is None: + print("ALX:end") + break + # print("ALX:searching ancestors for {}".format(item)) + # tree.put(path_to_list(item.get("Path")), get_item_details(item)) + all_item_s_ancestors = find_all_ancestors(client, item, tree) + print("ALX:found {} ancestors".format(len(all_item_s_ancestors))) + final_tree = combine_trees(final_tree, all_item_s_ancestors) + # tree.print() + result = recursive_tree_rebuild(tree.get_tree(), tree.get_records()) + # print("ALX:result={}".format(result)) + return result + + +def find_all_ancestors(client, item, tree): + # Find all the ancestors of an item + elements_paths_tokens, attributes_paths_tokens = path_to_list(item.get("Path")) + # print("ALX:search {}/{}".format(elements_paths_tokens, attributes_paths_tokens)) + parent_item = client.traverse_and_cache(elements_paths_tokens, attributes_paths_tokens, tree) + # print("ALX:parent_item={}".format(parent_item)) + return [] + + +def combine_trees(final_tree, all_item_s_ancestors): + # combine two trees with partial overlap and common root ancestor + return final_tree + + +# elements, attributes +def path_to_list(path): + if not path: + return [] + return path.split('|')[0].split('\\')[2:], (path.split('|')[1:]) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 6d67d206..140cecf9 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -37,17 +37,17 @@ - + +
+ -
- -
-
{{treeData | json}} + From 82156d6b8de67ef67fa209c3488684c735d6f6bf Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Thu, 18 Dec 2025 15:46:26 +0100 Subject: [PATCH 029/156] connect tree with osisoft data --- js/pi-system_treecontroller.js | 102 +++++++++++++++++----------- resource/pi-system_af-explorer.html | 18 ++--- 2 files changed, 68 insertions(+), 52 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 9d73b1b0..7507d1e4 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1,12 +1,28 @@ var app = angular.module('piSystemTreeApp.module', []); -app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', function($scope, $http, CreateModalFromTemplate) { - $scope.treeData = []; +app.service('TreeDataService', function() { + // This will store the shared tree data + this.treeData = []; + + // Optional: helper methods + this.setTreeData = function(data) { + this.treeData = data; + }; + + this.getTreeData = function() { + return this.treeData; + }; +}); + +app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', 'TreeDataService', function($scope, $http, CreateModalFromTemplate, TreeDataService) { +$scope.init = function() { $http.get('/plugins/pi-system/resource/tree.json') .then(function(response) { - $scope.treeData = response.data; - console.log($scope.treeData); + TreeDataService.setTreeData(response.data); + $scope.treeData = TreeDataService.getTreeData(); }) +} + // Toggle récursif des checkboxes $scope.toggleChildren = function(node) { @@ -27,18 +43,26 @@ app.controller('TreeCtrl', ['$scope', '$http','CreateModalFromTemplate', functio item.children = data.choices; item.children.forEach(child => { child.checked = item.checked; - child.expanded = false; + child.expanded = item.expanded; }); }); } }]); -app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMirrorSettingService) { +app.controller('AfExplorerFormCtrl', [ + '$scope', + '$stateParams', + 'CodeMirrorSettingService', + 'TreeDataService', + function($scope, $stateParams, CodeMirrorSettingService, TreeDataService) { + $scope.paramDesc = { 'parameterSetId': 'basic-auth', 'mandatory': true }; - + + $scope.treeData = TreeDataService.getTreeData(); + $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); $scope.init = function() { @@ -77,46 +101,42 @@ app.controller('AfExplorerFormController', function($scope, $stateParams, CodeMi $scope.database_name = data.choices; }); }; + $scope.initializeTree = function(){ console.log("ALX:initializeTree:scope=" + JSON.stringify($scope.config.database_name)); $scope.callPythonDo({method: "get_children_from_db", parent: $scope.config.database_name}).then(function(data){ - console.log("ALX:data2=" + JSON.stringify(data)); + console.log("ALX:data2=" + JSON.stringify(data)); + TreeDataService.setTreeData(data.choices); + $scope.treeData = TreeDataService.getTreeData(); }); }; - $scope.doSearch = function(element_name, attribute_name){ - console.log("ALX:search for ", element_name, attribute_name); - $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name}).then( - function(data){ - console.log("ALX:search result:", JSON.stringify(data)); - // $scope.treeData = data.choices; - $scope.$parent.treeData = [ - { - "id": 1, - "title": "Elements", - "expanded": false, - "checked": false, - "children": [ - { - "id": "blabla", - "url": "xcvb", - "title": "Well", - "expanded": false, - "checked": false, - "children": [ - ] - } - ] - } - ]; - // item.children.forEach(child => { - // child.checked = item.checked; - // child.expanded = false; - // }); - } - ); - }; + + $scope.getChildrenFromDB = function(item){ + console.log("ALX:gcfd:" + JSON.stringify(item)); + $scope.callPythonDo({ method: "get_children_from_db", parent: item }) + .then(function (data) { + console.log("ALX:data1=" + JSON.stringify(data)); + item.children = data.choices; + item.children.forEach(child => { + child.checked = item.checked; + child.expanded = false; + }); + }); + } + + + // Toggle récursif des checkboxes + $scope.toggleChildren = function(node) { + console.log("ALX:tc:" + JSON.stringify(node)); + if (node.children && node.children.length) { + node.children.forEach(function(child) { + child.checked = node.checked; + $scope.toggleChildren(child); + }); + } + }; -}); +}]); app.directive('treeNode', function() { return { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 6d67d206..cf36fa1d 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -1,4 +1,4 @@ -
+
@@ -36,18 +36,14 @@ ng-init="getDatabases()">
+
- - - -
- -
-
    +
    +
    -
    {{treeData | json}} +
+
+ From a456e21c19771c2edb77ff825489d6b226403276 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 22 Dec 2025 16:52:08 +0100 Subject: [PATCH 030/156] insert the result search into existing tree --- js/pi-system_treecontroller.js | 3 +-- python-lib/osisoft_plugin_common.py | 14 +++++++++- resource/browse_af_tree.py | 40 +++++++++++++++++------------ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 61184d7e..0156bf61 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -136,8 +136,7 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.doSearch = function(element_name, attribute_name){ - console.log("ALX:search for ", element_name, attribute_name); - $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name}).then( + $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.treeData}).then( function(data){ $scope.treeData = data.choices; } diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 4e2bd518..e7a1cb26 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -654,9 +654,21 @@ class Tree(): # Each put # - stores the data in the index # - builds a tree based on the data's path, pointing at the right index - def __init__(self): + def __init__(self, root_tree=None): self.tree = {} self.index = [] + if root_tree: + self._ingest(root_tree) + + def _ingest(self, root_tree): + if isinstance(root_tree, list): + for item in root_tree: + item_children = item.pop("children", []) + if item_children: + self._ingest(item_children) + path = item.get("path", "") + path_tokens = path.replace("|", "\\").split("\\")[2:] + self.put(path_tokens, item) def put(self, path, data): if isinstance(path, list): diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 2fb8fef6..fbe4b667 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -57,6 +57,7 @@ def do(payload, config, plugin_config, inputs): database_name = config.get("database_name") element_name = config.get("element_name") attribute_name = config.get("attribute_name") + root_tree = payload.get("root_tree") attributes = [] # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw database_webid = database_name.split("/")[-1] @@ -64,7 +65,7 @@ def do(payload, config, plugin_config, inputs): database_webid, attribute_name=attribute_name, element_name=element_name): # print("ALX:attribute={}".format(attribute)) attributes.append(attribute) - rebuilt_tree = rebuild_tree(client, attributes) + rebuilt_tree = rebuild_tree(client, attributes, root_tree) return {"choices": rebuilt_tree} parameter_name = payload.get("parameterName") @@ -142,35 +143,40 @@ def get_children_from_db(client, parent_node, database_name=None): # Tree class ? put(path, data), get(path, data) -def rebuild_tree(client, items): +def rebuild_tree(client, items, root_tree=None): # builds an active tree containing all the items and their parent up to the root - # print("ALX:items={}".format(len(items))) - tree = Tree() - final_tree = [] + tree = Tree(root_tree=root_tree) + tree.print() while len(items) > 1: - # print("ALX:items={}".format(items)) item = items.pop() if item is None: print("ALX:end") break - # print("ALX:searching ancestors for {}".format(item)) - # tree.put(path_to_list(item.get("Path")), get_item_details(item)) - all_item_s_ancestors = find_all_ancestors(client, item, tree) - print("ALX:found {} ancestors".format(len(all_item_s_ancestors))) - final_tree = combine_trees(final_tree, all_item_s_ancestors) - # tree.print() + find_all_ancestors(client, item, tree) result = recursive_tree_rebuild(tree.get_tree(), tree.get_records()) - # print("ALX:result={}".format(result)) + result = drop_first_levels(result) return result +def drop_first_levels(result): + # recursively removes the 2 first levels of the returned tree + # (server and DB) + output_result = [] + for item in result: + path = item.get("path", "") + path_length = len(path.split("\\")) + if path_length >= 5: + output_result.append(item) + else: + children = item.get("children", []) + output_result = drop_first_levels(children) + return output_result + + def find_all_ancestors(client, item, tree): # Find all the ancestors of an item elements_paths_tokens, attributes_paths_tokens = path_to_list(item.get("Path")) - # print("ALX:search {}/{}".format(elements_paths_tokens, attributes_paths_tokens)) - parent_item = client.traverse_and_cache(elements_paths_tokens, attributes_paths_tokens, tree) - # print("ALX:parent_item={}".format(parent_item)) - return [] + client.traverse_and_cache(elements_paths_tokens, attributes_paths_tokens, tree) def combine_trees(final_tree, all_item_s_ancestors): From 125f8c7968495905cdc1090098d4d90ae72c4b31 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 6 Jan 2026 11:15:35 +0100 Subject: [PATCH 031/156] fix on search --- js/pi-system_treecontroller.js | 6 +++--- python-lib/osisoft_client.py | 4 +++- python-lib/osisoft_plugin_common.py | 17 +++++++++------- resource/browse_af_tree.py | 30 ++++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 0156bf61..f95512eb 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -156,8 +156,8 @@ app.directive('treeNode', function() { - - + + @@ -170,7 +170,7 @@ app.directive('treeNode', function() { -
    +
    • diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 493b8bc4..ff757093 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -858,7 +858,9 @@ def traverse_and_cache(self, path_elements, path_attributes, tree): for path_attribute in path_attributes: # print("ALX:extract '{}' from {}".format(path_attribute, json_response)) item = self.extract_item_with_name(json_response, path_attribute) - tree.put(full_path_elements[0:counter], get_item_details(item)) + item_details = get_item_details(item) + item_details["checked"] = True + tree.put(full_path_elements[0:counter], item_details) counter += 1 next_url = self.extract_link_with_key(item, "Attributes") if next_url: diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index e7a1cb26..9c627673 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -660,15 +660,18 @@ def __init__(self, root_tree=None): if root_tree: self._ingest(root_tree) - def _ingest(self, root_tree): + def _ingest(self, root_tree, parent_path=None): + parent_path = parent_path or [] if isinstance(root_tree, list): for item in root_tree: + if not parent_path: + path = item.get("path", "") + parent_path = path.split("\\")[2:][0:2] item_children = item.pop("children", []) - if item_children: - self._ingest(item_children) + title = item.get("title") + self._ingest(item_children, parent_path=parent_path + [title]) path = item.get("path", "") - path_tokens = path.replace("|", "\\").split("\\")[2:] - self.put(path_tokens, item) + self.put(parent_path + [title], item) def put(self, path, data): if isinstance(path, list): @@ -741,10 +744,10 @@ def recursive_tree_rebuild(dictionary, records, counter=None): children = recursive_tree_rebuild(sub_dictionary, records, counter + 1) else: children = [] - context["id"] = str(counter) + # context["id"] = str(counter) context["title"] = key context["expanded"] = True - context["checked"] = False + # context["checked"] = False context["children"] = children output.append(context) return output diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index fbe4b667..dbcbf26d 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -58,12 +58,29 @@ def do(payload, config, plugin_config, inputs): element_name = config.get("element_name") attribute_name = config.get("attribute_name") root_tree = payload.get("root_tree") + root_tree = shorten_tree(root_tree) attributes = [] # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw database_webid = database_name.split("/")[-1] + # element_query_keys = { + # "element_name": "Name:'{}'", + # "search_root_path": "Root:'{}'", + # "element_template": "Template:'{}'", + # "element_type": "Type:'{}'", + # "element_category": "CategoryName:'{}'" + # } + # attribute_query_keys = { + # "attribute_name": "Name:'{}'", + # "attribute_category": "CategoryName:'{}'", + # "attribute_value_type": "Type:'{}'" + # } for attribute in client.search_attributes( - database_webid, attribute_name=attribute_name, element_name=element_name): + database_webid, + attribute_name=attribute_name, + element_name=element_name + ): # print("ALX:attribute={}".format(attribute)) + attribute["checked"] = True attributes.append(attribute) rebuilt_tree = rebuild_tree(client, attributes, root_tree) return {"choices": rebuilt_tree} @@ -189,3 +206,14 @@ def path_to_list(path): if not path: return [] return path.split('|')[0].split('\\')[2:], (path.split('|')[1:]) + + +def shorten_tree(tree): + if isinstance(tree, list): + for node in tree: + if "expanded" in node: + # node.pop("expanded", None) + node["expanded"] = False + if "children" in node: + shorten_tree(node.get("children", [])) + return tree From 29963d4ab0c93e45ffbbc33d1c4706a97982245f Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 6 Jan 2026 11:18:16 +0100 Subject: [PATCH 032/156] display duplicated attributes --- python-lib/osisoft_client.py | 2 ++ resource/browse_af_tree.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index ff757093..ce0e85cf 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -733,6 +733,8 @@ def search_attributes(self, database_webid, **kwargs): "query": query, "databaseWebId": database_webid } + if "search_associations" in kwargs: + params["associations"] = kwargs.get("search_associations") json_response = self.get(url=search_attributes_base_url, headers=headers, params=params) if OSIsoftConstants.DKU_ERROR_KEY in json_response: yield json_response diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index dbcbf26d..6d8d8d5c 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -77,11 +77,13 @@ def do(payload, config, plugin_config, inputs): for attribute in client.search_attributes( database_webid, attribute_name=attribute_name, - element_name=element_name + element_name=element_name, + search_associations="Paths" ): # print("ALX:attribute={}".format(attribute)) attribute["checked"] = True attributes.append(attribute) + attributes = duplicate_linked_attributes(attributes) rebuilt_tree = rebuild_tree(client, attributes, root_tree) return {"choices": rebuilt_tree} @@ -217,3 +219,14 @@ def shorten_tree(tree): if "children" in node: shorten_tree(node.get("children", [])) return tree + + +def duplicate_linked_attributes(attributes): + duplicated_attributes = [] + for attribute in attributes: + paths = attribute.pop("Paths", [attribute.get("Path")]) + for path in paths: + this_attribute = attribute.copy() + this_attribute["Path"] = path + duplicated_attributes.append(this_attribute) + return duplicated_attributes From 700eec6ea3564c749ef85659fcb4b4772b360172 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 7 Jan 2026 15:08:33 +0100 Subject: [PATCH 033/156] Edited files --- js/pi-system_treecontroller.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index f95512eb..6cba4b15 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -103,11 +103,15 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.initializeTree = function(){ + console.log("initialization: "); + console.log($scope.config.treeData); + if (!$scope.config.treeData || $scope.config.treeData===[]){ $scope.callPythonDo({method: "get_children_from_db", parent: $scope.config.database_name}).then(function(data){ console.log("ALX:data2=" + JSON.stringify(data)); TreeDataService.setTreeData(data.choices); - $scope.treeData = TreeDataService.getTreeData(); + $scope.config.treeData = TreeDataService.getTreeData(); }); + } }; $scope.getChildrenFromDB = function(item){ @@ -138,7 +142,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.doSearch = function(element_name, attribute_name){ $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.treeData}).then( function(data){ - $scope.treeData = data.choices; + $scope.config.treeData = data.choices; } ); }; From 3c39c7d16232e6c04a61fb34dc5c38414dd60a62 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 7 Jan 2026 15:09:18 +0100 Subject: [PATCH 034/156] Edited files --- js/pi-system_treecontroller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 6cba4b15..79f85ad3 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -142,7 +142,9 @@ app.controller('AfExplorerFormCtrl', [ $scope.doSearch = function(element_name, attribute_name){ $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.treeData}).then( function(data){ - $scope.config.treeData = data.choices; + //$scope.config.treeData = data.choices; + TreeDataService.setTreeData(data.choices); + $scope.config.treeData = TreeDataService.getTreeData(); } ); }; From c6669e56a69b807d7d4b086f5e66a33390999a29 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 7 Jan 2026 15:18:10 +0100 Subject: [PATCH 035/156] save and restore state --- resource/browse_af_tree.py | 2 ++ resource/pi-system_af-explorer.html | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 6d8d8d5c..9835fd98 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -111,6 +111,8 @@ def do(payload, config, plugin_config, inputs): return build_select_choices(choices) else: return build_select_choices() + if parameter_name == "treeData": + return {"choices": config.get("treeData")} return build_select_choices() diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 6506789b..5687bb1c 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -45,10 +45,20 @@ - +
      + +
      + +
      + +
      -
        -
      • +
          +
        From 988e75fc7f88f967f4247499db801bb6a8e0df07 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Thu, 8 Jan 2026 10:19:38 +0100 Subject: [PATCH 036/156] extend UI width --- resource/pi-system_af-explorer.css | 3 +++ resource/pi-system_af-explorer.html | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 resource/pi-system_af-explorer.css diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css new file mode 100644 index 00000000..76529135 --- /dev/null +++ b/resource/pi-system_af-explorer.css @@ -0,0 +1,3 @@ +.fh.w800.oa { + width: 100% !important; +} \ No newline at end of file diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 5687bb1c..689ec48b 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -1,3 +1,4 @@ +
        @@ -45,8 +46,8 @@ -
        - +
        +
        + {{attribute.title}} + {{attribute.path}} + +
        From c3c79d2cef4f5c0cfbb2043245e107bd6616dd31 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 21 Jan 2026 12:42:44 +0100 Subject: [PATCH 043/156] typo --- js/pi-system_treecontroller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 40a56156..105847ae 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -138,7 +138,7 @@ app.controller('AfExplorerFormCtrl', [ }; -$scope.newDisplayAttributes = function(node) { +$scope.displayAttributes = function(node) { if (!node.children || node.children.length === 0) { $scope.getChildrenFromDB(node).then(newNode => { From 0af06a73cf2379fd4e2adee35f91fe156db2615e Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 21 Jan 2026 12:55:19 +0100 Subject: [PATCH 044/156] fix elements highlight --- js/pi-system_treecontroller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 105847ae..09c97d83 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -224,7 +224,7 @@ app.directive('treeNode', function() { } return scope.$parent.config.attributeList.some(child => { - const expected = node.title + "|" + child.name; + const expected = node.title + "|" + child.title; return child.path.endsWith(expected); }); }; From 7d83faaa6a5acb7b5b0f82f9f0cb5ea47a543e4d Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 21 Jan 2026 13:59:14 +0100 Subject: [PATCH 045/156] little bit of UI --- resource/pi-system_af-explorer.css | 11 +++++- resource/pi-system_af-explorer.html | 59 +++++++++++++++++++---------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index 10bdd3c4..575492b3 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -4,4 +4,13 @@ .tree-node__label--clickable{ background-color: yellow; -} \ No newline at end of file +} +.pi-system-explorer__main { + display: grid; + grid-template-columns: 500px auto; + column-gap: 50px; + margin-top: 20px; +} +.pi-system-explorer__tree-view, .pi-system-explorer__center-view { + border: 1px solid #ccc; padding: 10px; border-radius: 5px; +} diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 2db29fc3..d331c889 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -39,13 +39,6 @@
        - - - - -
        @@ -57,20 +50,44 @@
        -
        -
          -
        • - -
        • -
        -
        -
        - - - - - -
        {{attribute.title}}{{attribute.path}}
        +
        +
        +
        Elements
        +
        + + +
        +
          +
        • + +
        • +
        +
        + + +
        +
        Attributes
        +
        + + +
        + + + + + + +
        + + {{attribute.title}}{{attribute.path}}
        +
        +
        + +
        From ddc57a8a69ad1f2417f039c73bffa001b16592a2 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Mon, 26 Jan 2026 10:48:50 +0100 Subject: [PATCH 046/156] merging template and category search on tree explorer --- js/pi-system_treecontroller.js | 21 ++++++++- python-lib/osisoft_client.py | 37 +++++++++++++++ resource/browse_af_tree.py | 71 +++++++++++++++++++++++++---- resource/pi-system_af-explorer.html | 16 +++++++ 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 09c97d83..aa3b16de 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -89,8 +89,24 @@ app.controller('AfExplorerFormCtrl', [ return item; }); } - - + + $scope.getTemplatesFromDB = function() { + $scope.callPythonDo({method: "get_templates_from_db"}).then(function(data){ + $scope.config.templates = data.choices; + }); + } + + $scope.getCategoriesFromDB = function(){ + $scope.config.attribute_categories = []; + $scope.config.element_categories = []; + $scope.callPythonDo({method: "get_attribute_categories_from_db"}).then(function(data){ + $scope.config.attribute_categories = data.choices; + }); + $scope.callPythonDo({method: "get_element_categories_from_db"}).then(function(data){ + $scope.config.element_categories = data.choices; + }); + } + // Toggle récursif des checkboxes $scope.toggleChildren = function(node) { console.log("ALX:tc:" + JSON.stringify(node)); @@ -110,6 +126,7 @@ app.controller('AfExplorerFormCtrl', [ function(data){ TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); + console.log("ALX:", JSON.stringify($scope.config.treeData)); $scope.config.attributeList = data.attributes; $scope.config.selectedAttributes = []; } diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 71c39c1e..5c853d91 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -748,6 +748,41 @@ def search_attributes(self, database_webid, **kwargs): else: json_response = None + def search_elements(self, database_webid, name=None, description=None, category=None, template=None, full_search=True): + headers = self.get_requests_headers() + tempo_maxcount = OSIsoftConstants.DEFAULT_MAXCOUNT + params = { + "maxCount": tempo_maxcount, + "associations": "Paths", + } + # # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-/elements?TemplateName=Substation transformer&categoryName=Westinghouse&nameFilter=TX26*&searchFullHierarchy=true + url = self.endpoint.get_base_url() + "/assetdatabases/{}/elements".format(database_webid) + if name: + params["nameFilter"] = name + if description: + params["descriptionFilter"] = description + if category: + params["categoryName"] = category + if template: + params["templateName"] = template + if full_search: + params["searchFullHierarchy"] = True + json_response = self.get(url=url, headers=headers, params=params) + if OSIsoftConstants.DKU_ERROR_KEY in json_response: + yield json_response + start_index = 0 + while json_response: + items = json_response.get(OSIsoftConstants.API_ITEM_KEY, []) + for item in items: + yield item + if len(items) < tempo_maxcount: + logger.info("No more result items") + return + start_index += tempo_maxcount + logger.info("Trying again with startIndex={}".format(start_index)) + params["startIndex"] = start_index + json_response = self.get(url=url, headers=headers, params=params) + def build_element_query(self, **kwargs): element_query_keys = { "element_name": "Name:'{}'", @@ -866,6 +901,8 @@ def traverse_and_cache(self, path_elements, path_attributes, tree): json_response = self.get(url=next_url, headers=headers, params={}, error_source="traverse_and_cache") else: break + if not before_last_json: + return None items = before_last_json.get(OSIsoftConstants.API_ITEM_KEY, []) for item in items: item_details = get_item_details(item) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 91b3f6dd..dbf6984d 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -51,7 +51,28 @@ def do(payload, config, plugin_config, inputs): database_name = config.get("database_name") parent = payload.get("parent", {}) return get_children_from_db(client, parent, database_name=database_name) + if method == "get_templates_from_db": + database_name = config.get("database_name") + parent = payload.get("parent", {}) + ret = get_items_from_db(client, parent, "ElementTemplates", database_name=database_name) + return ret + if method == "get_attribute_categories_from_db": + database_name = config.get("database_name") + parent = payload.get("parent", {}) + ret = get_items_from_db(client, parent, "AttributeCategories", database_name=database_name) + return ret + if method == "get_element_categories_from_db": + database_name = config.get("database_name") + parent = payload.get("parent", {}) + ret = get_items_from_db(client, parent, "ElementCategories", database_name=database_name) + return ret if method == "do_search": + template_name = config.get("template", None) + category_name = config.get("element_category", None) + if template_name == "-- Any --": + template_name = None + if category_name == "-- Any --": + category_name = None database_name = config.get("database_name") element_name = config.get("element_name") attribute_name = config.get("attribute_name") @@ -73,14 +94,29 @@ def do(payload, config, plugin_config, inputs): # "attribute_category": "CategoryName:'{}'", # "attribute_value_type": "Type:'{}'" # } - for attribute in client.search_attributes( - database_webid, - attribute_name=attribute_name, - element_name=element_name, - search_associations="Paths" - ): - attribute["checked"] = False - attributes.append(attribute) + # for attribute in client.search_attributes( + # database_webid, + # attribute_name=attribute_name, + # element_name=element_name, + # search_associations="Paths" + # ): + # attribute["checked"] = False + # attributes.append(attribute) + attributes = [] + if template_name or category_name: + for attribute in client.search_elements(database_webid, name=element_name, template=template_name, category=category_name, full_search=True): + attribute["checked"] = True + attributes.append(attribute) + else: + for attribute in client.search_attributes( + database_webid, + attribute_name=attribute_name, + element_name=element_name, + search_associations="Paths" + ): + attribute["checked"] = True + attributes.append(attribute) + attributes = duplicate_linked_attributes(attributes) items = [] for attribute in attributes: @@ -123,6 +159,25 @@ def get_query_catalogs(cnx, config): return {"choices": [user, password]} +def get_items_from_db(client, parent_node, link_key, database_name=None): + default_choice = {"title": "-- Any --"} + if isinstance(parent_node, dict): + url = parent_node.get("url", database_name) + else: + url = parent_node + this_node = next(client.get_next_item_from_url(url)) + links = this_node.get("Links", {}) + items_url = links.get(link_key) + items = [] + items.append(default_choice) + if items_url: + for item in client.get_next_item_from_url(items_url): + item = get_item_details(item) + item["type"] = link_key + items.append(item) + return {"choices": items} + + def get_children_from_db(client, parent_node, database_name=None): if isinstance(parent_node, dict): url = parent_node.get("url", database_name) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index d331c889..4330f56a 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -56,9 +56,20 @@
        + +
        +
        • @@ -72,6 +83,11 @@
          +
          From ad1402f3f91e1dcaca5658a4c1b59ab5c42fe00a Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 27 Jan 2026 10:26:25 +0100 Subject: [PATCH 047/156] use more generic batched search approach --- python-lib/osisoft_client.py | 57 +++++++++++++++++++++++++++++++++++- resource/browse_af_tree.py | 43 +++++++-------------------- 2 files changed, 66 insertions(+), 34 deletions(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 5c853d91..7fb91ee0 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -783,6 +783,61 @@ def search_elements(self, database_webid, name=None, description=None, category= params["startIndex"] = start_index json_response = self.get(url=url, headers=headers, params=params) + def batched_search(self, element_name, attribute_name, element_category, attribute_category, template): + attribute_query = { + "searchFullHierarchy": "true", + "selectedFields": "Items.WebId;Items.Path"} + if attribute_name: + attribute_query["nameFilter"] = attribute_name + if attribute_category: + attribute_query["categoryName"] = attribute_category + request_body = { + "database": { + "Method": "GET", + "Resource": "https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases?path=\\\\osisoft-pi-serv\\Well&selectedFields=WebId;Path;Links" + }, + "elements": { + "Method": "GET", + "Resource": "{{0}}{}".format( + build_query_string("", { + "templateName": template, + "categoryName": element_category, + "nameFilter": element_name, + "searchFullHierarchy": "true", + "selectedFields": "Items.WebId;Items.Path;Items.Links" + } + ) + ), + "ParentIds": ["database"], + "Parameters": ["$.database.Content.Links.Elements"] + }, + "attributes": { + "Method": "GET", + "RequestTemplate": { + "Resource": "{{0}}{}".format( + build_query_string("", attribute_query) + ) #?searchFullHierarchy=true&selectedFields=Items.WebId;Items.Path" + }, # build_attribute_query(attribute_name, attribute_category) + "ParentIds": ["elements"], + "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] + } + } + url = self.endpoint.get_batch_endpoint() + headers = OSIsoftConstants.WRITE_HEADERS + response = self.post(url, headers=headers, data=request_body, params={}) + json_response = response.json() + attributes = json_response.get("attributes", {}) + attributes_content = attributes.get("Content", {}) + if not isinstance(attributes_content, dict): + # the search returned nothing + return + attributes_content_items = attributes_content.get("Items", []) + for attributes_content_item in attributes_content_items: + content = attributes_content_item.get("Content", {}) + sub_items = content.get("Items", []) + for sub_item in sub_items: + yield sub_item + def build_element_query(self, **kwargs): element_query_keys = { "element_name": "Name:'{}'", @@ -1152,7 +1207,7 @@ def build_query_string(url, params): if isinstance(value, list): for element in value: tokens.append(key+"="+str(element)) - else: + elif value is not None: tokens.append(key+"="+str(value)) if len(tokens) > 0: return url + "?" + "&".join(tokens) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index dbf6984d..22be6e67 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -73,6 +73,12 @@ def do(payload, config, plugin_config, inputs): template_name = None if category_name == "-- Any --": category_name = None + element_category = config.get("element_category", None) + if element_category == "-- Any --": + element_category = None + attribute_category = config.get("attribute_category", None) + if attribute_category == "-- Any --": + attribute_category = None database_name = config.get("database_name") element_name = config.get("element_name") attribute_name = config.get("attribute_name") @@ -82,40 +88,11 @@ def do(payload, config, plugin_config, inputs): attributes = [] # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw database_webid = database_name.split("/")[-1] - # element_query_keys = { - # "element_name": "Name:'{}'", - # "search_root_path": "Root:'{}'", - # "element_template": "Template:'{}'", - # "element_type": "Type:'{}'", - # "element_category": "CategoryName:'{}'" - # } - # attribute_query_keys = { - # "attribute_name": "Name:'{}'", - # "attribute_category": "CategoryName:'{}'", - # "attribute_value_type": "Type:'{}'" - # } - # for attribute in client.search_attributes( - # database_webid, - # attribute_name=attribute_name, - # element_name=element_name, - # search_associations="Paths" - # ): - # attribute["checked"] = False - # attributes.append(attribute) + attributes = [] - if template_name or category_name: - for attribute in client.search_elements(database_webid, name=element_name, template=template_name, category=category_name, full_search=True): - attribute["checked"] = True - attributes.append(attribute) - else: - for attribute in client.search_attributes( - database_webid, - attribute_name=attribute_name, - element_name=element_name, - search_associations="Paths" - ): - attribute["checked"] = True - attributes.append(attribute) + for result in client.batched_search(element_name, attribute_name, element_category, attribute_category, template_name): + result["checked"] = True + attributes.append(result) attributes = duplicate_linked_attributes(attributes) items = [] From 5f030e77c112b1dae8236d9bc4d2e1f7eb8dd9de Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 27 Jan 2026 15:45:38 +0100 Subject: [PATCH 048/156] fix virtual links --- python-lib/osisoft_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 7fb91ee0..f02f429c 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -785,8 +785,9 @@ def search_elements(self, database_webid, name=None, description=None, category= def batched_search(self, element_name, attribute_name, element_category, attribute_category, template): attribute_query = { - "searchFullHierarchy": "true", - "selectedFields": "Items.WebId;Items.Path"} + "searchFullHierarchy": "true", + "associations": "Paths" + } if attribute_name: attribute_query["nameFilter"] = attribute_name if attribute_category: @@ -804,7 +805,7 @@ def batched_search(self, element_name, attribute_name, element_category, attribu "categoryName": element_category, "nameFilter": element_name, "searchFullHierarchy": "true", - "selectedFields": "Items.WebId;Items.Path;Items.Links" + "associations": "Paths" } ) ), From 19af2862fef2a0bb7d380e725a365fdec2639c3e Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 3 Feb 2026 13:17:58 +0100 Subject: [PATCH 049/156] use the database name for batched search --- python-lib/osisoft_client.py | 46 +++++++++++++++++++++++++++++------- resource/browse_af_tree.py | 2 +- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index f02f429c..d90aaa17 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -783,7 +783,7 @@ def search_elements(self, database_webid, name=None, description=None, category= params["startIndex"] = start_index json_response = self.get(url=url, headers=headers, params=params) - def batched_search(self, element_name, attribute_name, element_category, attribute_category, template): + def batched_search(self, database, element_name, attribute_name, element_category, attribute_category, template): attribute_query = { "searchFullHierarchy": "true", "associations": "Paths" @@ -792,14 +792,13 @@ def batched_search(self, element_name, attribute_name, element_category, attribu attribute_query["nameFilter"] = attribute_name if attribute_category: attribute_query["categoryName"] = attribute_category + elements_url = "{}/elements".format(database) + print("ALX:fetching direct from {}".format(elements_url)) request_body = { - "database": { - "Method": "GET", - "Resource": "https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases?path=\\\\osisoft-pi-serv\\Well&selectedFields=WebId;Path;Links" - }, "elements": { "Method": "GET", - "Resource": "{{0}}{}".format( + "Resource": "{}{}".format( + elements_url, build_query_string("", { "templateName": template, "categoryName": element_category, @@ -808,9 +807,7 @@ def batched_search(self, element_name, attribute_name, element_category, attribu "associations": "Paths" } ) - ), - "ParentIds": ["database"], - "Parameters": ["$.database.Content.Links.Elements"] + ) }, "attributes": { "Method": "GET", @@ -823,6 +820,37 @@ def batched_search(self, element_name, attribute_name, element_category, attribu "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] } } + # request_body = { + # "database": { + # "Method": "GET", + # "Resource": "https://osisoft/piwebapi/assetdatabases?path=\\\\osisoft-pi-serv\\Well&selectedFields=WebId;Path;Links" + # }, + # "elements": { + # "Method": "GET", + # "Resource": "{{0}}{}".format( + # build_query_string("", { + # "templateName": template, + # "categoryName": element_category, + # "nameFilter": element_name, + # "searchFullHierarchy": "true", + # "associations": "Paths" + # } + # ) + # ), + # "ParentIds": ["database"], + # "Parameters": ["$.database.Content.Links.Elements"] + # }, + # "attributes": { + # "Method": "GET", + # "RequestTemplate": { + # "Resource": "{{0}}{}".format( + # build_query_string("", attribute_query) + # ) #?searchFullHierarchy=true&selectedFields=Items.WebId;Items.Path" + # }, # build_attribute_query(attribute_name, attribute_category) + # "ParentIds": ["elements"], + # "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] + # } + # } url = self.endpoint.get_batch_endpoint() headers = OSIsoftConstants.WRITE_HEADERS response = self.post(url, headers=headers, data=request_body, params={}) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 22be6e67..94737f82 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -90,7 +90,7 @@ def do(payload, config, plugin_config, inputs): database_webid = database_name.split("/")[-1] attributes = [] - for result in client.batched_search(element_name, attribute_name, element_category, attribute_category, template_name): + for result in client.batched_search(database_name, element_name, attribute_name, element_category, attribute_category, template_name): result["checked"] = True attributes.append(result) From 0940e2cab7ceebd3feb79ea0deb7500ac7212de7 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 3 Feb 2026 15:35:00 +0100 Subject: [PATCH 050/156] UI for authentication params --- js/pi-system_treecontroller.js | 26 +++++++++ resource/pi-system_af-explorer.css | 13 +++++ resource/pi-system_af-explorer.html | 85 +++++++++++++++++------------ 3 files changed, 88 insertions(+), 36 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index aa3b16de..7965f464 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -51,6 +51,10 @@ app.controller('AfExplorerFormCtrl', [ return p.description || 'No description'; }); }).error(setErrorInScope.bind($scope.errorScope)); + if($scope.authConfigured() === true){ + $scope.authSectionVisible = false; + $scope.showTreeData = true; + } }; $scope.getServers = function(){ @@ -63,6 +67,28 @@ app.controller('AfExplorerFormCtrl', [ $scope.database_name = data.choices; }); }; + + $scope.authSectionVisible = $scope.authSectionVisible || true; + + $scope.toggleAuthSection = function() { + $scope.authSectionVisible = !$scope.authSectionVisible; + }; + + $scope.authConfigured = function(){ + console.log('authConfigured check'); + return $scope.hasPreset() && $scope.config.database_name && $scope.config.database_name.length > 0 && $scope.config.server_name && $scope.config.server_name.length > 0 ; + } + $scope.explore = function() { + console.log("coucou"); + if ($scope.authConfigured()) { + console.log("here"); + $scope.showTreeData = true; + } + }; + + $scope.hasPreset = function() { + return $scope.config.credentials && $scope.config.credentials.mode && $scope.config.credentials.mode!=='NONE' && $scope.config.credentials.name + } $scope.initializeTree = function(){ console.log("initialization: "); diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index 575492b3..2ef20fd4 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -14,3 +14,16 @@ .pi-system-explorer__tree-view, .pi-system-explorer__center-view { border: 1px solid #ccc; padding: 10px; border-radius: 5px; } + +.pi-system-explorer__authentication-header { + background-color: #f0f0f0; + padding: 10px; + cursor: pointer; + border: 1px solid #ccc; + border-radius: 5px; + font-weight: bold; +} + +.pi-system-explorer__authentication-body { + border: 1px solid #ccc; padding: 10px; border-radius: 5px; +} \ No newline at end of file diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 4330f56a..e9406128 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -1,43 +1,56 @@
          -
          - -
          -
          +
          +
          + Welcome to Pi System Plugin +
          +
          +
          + +
          +
          +
          +
          + +
          + +
          +
          +
          + +
          + +
          +
          + Use SSL +
          +
          + +
          -
          - -
          - -
          -
          -
          - -
          - -
          -
          @@ -50,7 +63,7 @@
          -
          +
          Elements
          From ca4f68ae4ba8c2f4615056bc15ffce93555adf1a Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 3 Feb 2026 20:52:24 +0100 Subject: [PATCH 051/156] *temporary frontend fix to have attributes unselected by default after a search * highlight elements when clicking on it from the tree view * clear element input when clicking on an other element from the tree view --- js/pi-system_treecontroller.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 7965f464..040662d1 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -153,7 +153,8 @@ app.controller('AfExplorerFormCtrl', [ TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); console.log("ALX:", JSON.stringify($scope.config.treeData)); - $scope.config.attributeList = data.attributes; + //todo: correct fix is to not have checked:true in the backend in the first place + $scope.config.attributeList = data.attributes.map(el=>({...el, checked:false})); $scope.config.selectedAttributes = []; } ); @@ -193,12 +194,15 @@ $scope.displayAttributes = function(node) { } function processNode(node) { + if (node.title !== $scope.config.element_name) { + $scope.config.element_name = ""; + } $scope.config.attributeList = []; $scope.config.selectedAttributes = []; node.children.forEach(child => { if (child.type === "attribute") { $scope.config.attributeList.push({ - "name": child.title, + "title": child.title, "path": child.path }); } From 533a77375a0669b348d3e6527f5211f8ff2ce207 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 3 Feb 2026 21:02:08 +0100 Subject: [PATCH 052/156] *use js better practices *formatting --- js/pi-system_treecontroller.js | 326 ++++++++++++++-------------- resource/pi-system_af-explorer.html | 146 ++++++------- 2 files changed, 232 insertions(+), 240 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 040662d1..55807e08 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1,84 +1,84 @@ var app = angular.module('piSystemTreeApp.module', []); -app.service('TreeDataService', function() { +app.service('TreeDataService', function () { // This will store the shared tree data this.treeData = []; // Optional: helper methods - this.setTreeData = function(data) { + this.setTreeData = function (data) { this.treeData = data; }; - this.getTreeData = function() { + this.getTreeData = function () { return this.treeData; }; }); app.controller('AfExplorerFormCtrl', [ - '$scope', - '$stateParams', - 'CodeMirrorSettingService', - 'TreeDataService', - function($scope, $stateParams, CodeMirrorSettingService, TreeDataService) { - + '$scope', + '$stateParams', + 'CodeMirrorSettingService', + 'TreeDataService', + function ($scope, $stateParams, CodeMirrorSettingService, TreeDataService) { + $scope.paramDesc = { 'parameterSetId': 'basic-auth', 'mandatory': true }; - + $scope.treeData = TreeDataService.getTreeData(); $scope.config.attributeList = $scope.config.attributeList || []; $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; - + $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); - $scope.init = function() { - DataikuAPI.plugins.listAccessiblePresets('pi-system', $stateParams.projectKey, 'basic-auth').success(function (data) { - $scope.inlineParams = data.inlineParams; - $scope.inlinePluginParams = data.inlinePluginParams; - $scope.accessiblePresets = []; - if (data.definableInline) { - $scope.accessiblePresets.push({ - name:"INLINE", - label:"Manually defined", usable:true, - description: "Define values for these parameters" - }); - } - data.presets.forEach(function(p) { - $scope.accessiblePresets.push({name:"PRESET " + p.name, label:p.name, usable:p.usable, description:p.description}); - }); - $scope.accessibleParameterSetDescriptions = $scope.accessiblePresets.map(function(p) { - return p.description || 'No description'; - }); - }).error(setErrorInScope.bind($scope.errorScope)); - if($scope.authConfigured() === true){ - $scope.authSectionVisible = false; - $scope.showTreeData = true; + $scope.init = function () { + DataikuAPI.plugins.listAccessiblePresets('pi-system', $stateParams.projectKey, 'basic-auth').success(function (data) { + $scope.inlineParams = data.inlineParams; + $scope.inlinePluginParams = data.inlinePluginParams; + $scope.accessiblePresets = []; + if (data.definableInline) { + $scope.accessiblePresets.push({ + name: "INLINE", + label: "Manually defined", usable: true, + description: "Define values for these parameters" + }); } + data.presets.forEach(function (p) { + $scope.accessiblePresets.push({ name: "PRESET " + p.name, label: p.name, usable: p.usable, description: p.description }); + }); + $scope.accessibleParameterSetDescriptions = $scope.accessiblePresets.map(function (p) { + return p.description || 'No description'; + }); + }).error(setErrorInScope.bind($scope.errorScope)); + if ($scope.authConfigured() === true) { + $scope.authSectionVisible = false; + $scope.showTreeData = true; + } }; - - $scope.getServers = function(){ - $scope.callPythonDo({parameterName: "server_name"}).then(function(data){ + + $scope.getServers = function () { + $scope.callPythonDo({ parameterName: "server_name" }).then(function (data) { $scope.server_name = data.choices; }); }; - $scope.getDatabases = function() { - $scope.callPythonDo({parameterName: "database_name"}).then(function(data){ + $scope.getDatabases = function () { + $scope.callPythonDo({ parameterName: "database_name" }).then(function (data) { $scope.database_name = data.choices; }); }; $scope.authSectionVisible = $scope.authSectionVisible || true; - $scope.toggleAuthSection = function() { + $scope.toggleAuthSection = function () { $scope.authSectionVisible = !$scope.authSectionVisible; }; - $scope.authConfigured = function(){ + $scope.authConfigured = function () { console.log('authConfigured check'); - return $scope.hasPreset() && $scope.config.database_name && $scope.config.database_name.length > 0 && $scope.config.server_name && $scope.config.server_name.length > 0 ; + return $scope.hasPreset() && $scope.config.database_name && $scope.config.database_name.length > 0 && $scope.config.server_name && $scope.config.server_name.length > 0; } - $scope.explore = function() { + $scope.explore = function () { console.log("coucou"); if ($scope.authConfigured()) { console.log("here"); @@ -86,178 +86,184 @@ app.controller('AfExplorerFormCtrl', [ } }; - $scope.hasPreset = function() { - return $scope.config.credentials && $scope.config.credentials.mode && $scope.config.credentials.mode!=='NONE' && $scope.config.credentials.name + $scope.hasPreset = function () { + return $scope.config.credentials && $scope.config.credentials.mode && $scope.config.credentials.mode !== 'NONE' && $scope.config.credentials.name } - - $scope.initializeTree = function(){ - console.log("initialization: "); - console.log($scope.config.treeData); - if (!$scope.config.treeData || $scope.config.treeData.length === 0){ - $scope.callPythonDo({method: "get_children_from_db", parent: $scope.config.database_name}).then(function(data){ + + $scope.initializeTree = function () { + console.log("initialization: "); + console.log($scope.config.treeData); + if (!$scope.config.treeData || $scope.config.treeData.length === 0) { + $scope.callPythonDo({ method: "get_children_from_db", parent: $scope.config.database_name }).then(function (data) { console.log("ALX:data2=" + JSON.stringify(data)); TreeDataService.setTreeData(data.choices); - $scope.config.treeData = TreeDataService.getTreeData(); - }); - } + $scope.config.treeData = TreeDataService.getTreeData(); + }); + } }; - - $scope.getChildrenFromDB = function(item){ - console.log("ALX:gcfd:" + JSON.stringify(item)); - return $scope.callPythonDo({ method: "get_children_from_db", parent: item }) - .then(function (data) { - console.log("ALX:data1=" + JSON.stringify(data)); - item.children = data.choices; - item.children.forEach(child => { - child.expanded = false; + + $scope.getChildrenFromDB = function (item) { + console.log("ALX:gcfd:" + JSON.stringify(item)); + return $scope.callPythonDo({ method: "get_children_from_db", parent: item }) + .then(function (data) { + console.log("ALX:data1=" + JSON.stringify(data)); + item.children = data.choices; + item.children.forEach(child => { + child.expanded = false; + }); + console.log(item); + return item; }); - console.log(item); - return item; - }); - } + } - $scope.getTemplatesFromDB = function() { - $scope.callPythonDo({method: "get_templates_from_db"}).then(function(data){ + $scope.getTemplatesFromDB = function () { + $scope.callPythonDo({ method: "get_templates_from_db" }).then(function (data) { $scope.config.templates = data.choices; }); } - $scope.getCategoriesFromDB = function(){ + $scope.getCategoriesFromDB = function () { $scope.config.attribute_categories = []; $scope.config.element_categories = []; - $scope.callPythonDo({method: "get_attribute_categories_from_db"}).then(function(data){ + $scope.callPythonDo({ method: "get_attribute_categories_from_db" }).then(function (data) { $scope.config.attribute_categories = data.choices; }); - $scope.callPythonDo({method: "get_element_categories_from_db"}).then(function(data){ + $scope.callPythonDo({ method: "get_element_categories_from_db" }).then(function (data) { $scope.config.element_categories = data.choices; }); } - // Toggle récursif des checkboxes - $scope.toggleChildren = function(node) { - console.log("ALX:tc:" + JSON.stringify(node)); + // Toggle récursif des checkboxes + $scope.toggleChildren = function (node) { + console.log("ALX:tc:" + JSON.stringify(node)); node.expanded = !node.expanded; $scope.getChildrenFromDB(node); - if (node.children && node.children.length) { - node.children.forEach(function(child) { - child.expanded = !child.expanded; - $scope.getChildrenFromDB(child); - }); - } - - }; + if (node.children && node.children.length) { + node.children.forEach(function (child) { + child.expanded = !child.expanded; + $scope.getChildrenFromDB(child); + }); + } + + }; - $scope.doSearch = function(element_name, attribute_name){ - $scope.callPythonDo({method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData}).then( - function(data){ + $scope.doSearch = function (element_name, attribute_name) { + $scope.callPythonDo({ method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData }).then( + function (data) { TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); console.log("ALX:", JSON.stringify($scope.config.treeData)); //todo: correct fix is to not have checked:true in the backend in the first place - $scope.config.attributeList = data.attributes.map(el=>({...el, checked:false})); + $scope.config.attributeList = data.attributes.map(el => ({ ...el, checked: false })); $scope.config.selectedAttributes = []; } ); }; - $scope.updateAttributeToOutput = function (attribute) { - if (attribute.checked && $scope.config.selectedAttributes.includes(attribute)) { - $scope.config.selectedAttributes = $scope.config.selectedAttributes.filter(attr => attr.path !== attribute.path); - } - else { - console.log("Adding attribute to output:", attribute); - - if (!$scope.config || !$scope.config.attributeList || $scope.config.selectedAttributes.includes(attribute)) { - return; + $scope.updateAttributeToOutput = function (attribute) { + if (attribute.checked && $scope.config.selectedAttributes.includes(attribute)) { + $scope.config.selectedAttributes = $scope.config.selectedAttributes.filter(attr => attr.path !== attribute.path); } - const attrInConfig = $scope.config.attributeList.find(attr => attr.path === attribute.path); + else { + console.log("Adding attribute to output:", attribute); - if (attrInConfig) { - $scope.config.selectedAttributes.push(attribute); - attrInConfig.checked = true; - } else { - console.warn("Attribute not found in config:", attribute.path); + if (!$scope.config || !$scope.config.attributeList || $scope.config.selectedAttributes.includes(attribute)) { + return; + } + const attrInConfig = $scope.config.attributeList.find(attr => attr.path === attribute.path); + + if (attrInConfig) { + $scope.config.selectedAttributes.push(attribute); + attrInConfig.checked = true; + } else { + console.warn("Attribute not found in config:", attribute.path); + } } - } - }; + }; -$scope.displayAttributes = function(node) { + $scope.displayAttributes = function (node) { - if (!node.children || node.children.length === 0) { + if (!node.children || node.children.length === 0) { $scope.getChildrenFromDB(node).then(newNode => { - processNode(newNode); + processNode(newNode); }); - } else { + } else { processNode(node); - }; - } - -function processNode(node) { - if (node.title !== $scope.config.element_name) { - $scope.config.element_name = ""; + }; } - $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; - node.children.forEach(child => { + + function processNode(node) { + if (node.title !== $scope.config.element_name) { + $scope.config.element_name = ""; + } + $scope.config.attributeList = []; + $scope.config.selectedAttributes = []; + node.children.forEach(child => { if (child.type === "attribute") { - $scope.config.attributeList.push({ - "title": child.title, - "path": child.path - }); + $scope.config.attributeList.push({ + "title": child.title, + "path": child.path + }); } - }); -} + }); + } -}]); + }]); -app.directive('treeNode', function() { +app.directive('treeNode', function () { return { restrict: 'E', - scope: { node: '=' }, + scope: { + node: '=', // mutable object (tree structure changes) + getChildrenFromDB: '<', // function reference + displayAttributes: '<', // function reference + config: '<' // read/write nested properties only + }, + template: `
          - - - - - -
          - - {{ node.title }} + + {{ node.title }} +
          -
          -
            -
          • - +
              + +
            • + + + +
            • +
            `, - link: function(scope) { - scope.toggleChildren = scope.$parent.toggleChildren; - scope.getChildrenFromDB = scope.$parent.getChildrenFromDB; - scope.doSearch = scope.$parent.doSearch; - scope.config = scope.$parent.config; - scope.attributeList = scope.config.attributeList || []; - scope.displayAttributes = scope.$parent.displayAttributes; - scope.toggleExpand = function(node) { + + link: function (scope) { + + // Toggle expansion + lazy loading + scope.toggleExpand = function (node) { node.expanded = !node.expanded; if (node.expanded && (!node.children || !node.children.length)) { @@ -265,20 +271,20 @@ app.directive('treeNode', function() { } }; - scope.hasAttributes = function(node) { - if (!Array.isArray(scope.$parent.config.attributeList) || scope.$parent.config.attributeList.length === 0) { + + scope.hasAttributes = function (node) { + if (!Array.isArray(scope.config.attributeList) || + !scope.config.attributeList.length) { return false; } - return scope.$parent.config.attributeList.some(child => { - const expected = node.title + "|" + child.title; - return child.path.endsWith(expected); + return scope.config.attributeList.some(attr => { + const expected = node.title + "|" + attr.title; + return attr.path.endsWith(expected); }); }; - scope.isElement = function(child) { - return child.type === 'element'; - } } }; }); + diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index e9406128..110f5524 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -4,119 +4,105 @@
            Welcome to Pi System Plugin
            -
            -
            - -
            -
            +
            +
            + +
            +
            +
            -
            -
            - -
            - -
            -
            -
            - -
            - +
            + +
            + +
            -
            - Use SSL +
            + +
            + +
            +
            + Use SSL +
            + +
            - -
            -
            -
            - -
            - -
            +
            + +
            + +
            -
            +
            Elements
            - - + - - + +
            • - + +
            - +
            Attributes
            - - - + + +
            - + {{attribute.title}} {{attribute.path}}
            - -
            +
            -
            +
            \ No newline at end of file From 572b69d2dac1a4bc47e16093caaeb46a8ff01a82 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 3 Feb 2026 21:20:22 +0100 Subject: [PATCH 053/156] *add attribute description and hide path *put the attributes in a styled table --- js/pi-system_treecontroller.js | 3 ++- python-lib/osisoft_plugin_common.py | 3 ++- resource/pi-system_af-explorer.css | 28 +++++++++++++++++++++++++- resource/pi-system_af-explorer.html | 31 +++++++++++++++++++---------- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 55807e08..cfcb4450 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -203,7 +203,8 @@ app.controller('AfExplorerFormCtrl', [ if (child.type === "attribute") { $scope.config.attributeList.push({ "title": child.title, - "path": child.path + "path": child.path, + "description": child.description }); } }); diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 82cc5802..81692a09 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -154,6 +154,7 @@ def build_requests_params(**kwargs): "boundary_type": "syncTimeBoundaryType", "name_filter": "nameFilter", "category_name": "categoryName", + "description": "descriptionFilter", "template_name": "templateName", "referenced_element_name_filter": "referencedElementNameFilter", "referenced_element_template": "referencedElementTemplate", @@ -638,7 +639,7 @@ def add(self, start_time, end_time, interval): def get_item_details(item): KEYS_TO_CHECK = { - "Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", + "Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", "Description": "description", "HasChildren": "has_children", "Path": "path", "WebId": "id", "checked": "checked" } # should we stick to python naming convention or keep pi's ones throughout ? details = {} diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index 2ef20fd4..27406811 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -26,4 +26,30 @@ .pi-system-explorer__authentication-body { border: 1px solid #ccc; padding: 10px; border-radius: 5px; -} \ No newline at end of file +} + +.custom-table { + border-collapse: collapse; + width: 100%; + font-family: Arial, sans-serif; +} + +.custom-table th, +.custom-table td { + border: 1px solid #ccc; + padding: 8px 12px; + text-align: left; +} + +.custom-table th { + background-color: #f4f4f4; + font-weight: bold; +} + +.custom-table tr:nth-child(even) { + background-color: #fafafa; +} + +.custom-table tr:hover { + background-color: #f1f7ff; +} diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 110f5524..7acdc25b 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -90,16 +90,27 @@
            - - - - - - -
            - - {{attribute.title}}{{attribute.path}}
            + + + + + + + + + + + + + + + + +
            SelectTitleDescription
            + + {{attribute.title}}{{attribute.description}}
          From d5ab146b4f492c4b81453a67f8f5a02fb240c894 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 4 Feb 2026 10:14:54 +0100 Subject: [PATCH 054/156] better handling of check boxes --- js/pi-system_treecontroller.js | 4 +--- python-lib/osisoft_client.py | 5 ++--- resource/browse_af_tree.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index cfcb4450..9c850a3e 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -152,9 +152,7 @@ app.controller('AfExplorerFormCtrl', [ function (data) { TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); - console.log("ALX:", JSON.stringify($scope.config.treeData)); - //todo: correct fix is to not have checked:true in the backend in the first place - $scope.config.attributeList = data.attributes.map(el => ({ ...el, checked: false })); + $scope.config.attributeList = data.attributes; $scope.config.selectedAttributes = []; } ); diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index d90aaa17..7677f85c 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -755,7 +755,6 @@ def search_elements(self, database_webid, name=None, description=None, category= "maxCount": tempo_maxcount, "associations": "Paths", } - # # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-/elements?TemplateName=Substation transformer&categoryName=Westinghouse&nameFilter=TX26*&searchFullHierarchy=true url = self.endpoint.get_base_url() + "/assetdatabases/{}/elements".format(database_webid) if name: params["nameFilter"] = name @@ -976,7 +975,7 @@ def traverse_and_cache(self, path_elements, path_attributes, tree): for path_attribute in path_attributes: item = self.extract_item_with_name(json_response, path_attribute) item_details = get_item_details(item) - item_details["checked"] = True # That should not be done here + # item_details["checked"] = True # That should not be done here tree.put(full_path_elements[0:counter], item_details) counter += 1 next_url = self.extract_link_with_key(item, "Attributes") @@ -1212,7 +1211,7 @@ def close(self): def validate_timestamp(timestamp): - valid_formats=["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"] + valid_formats = ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"] for valid_format in valid_formats: try: datetime.strptime(timestamp, valid_format) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 94737f82..74118d78 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -91,7 +91,7 @@ def do(payload, config, plugin_config, inputs): attributes = [] for result in client.batched_search(database_name, element_name, attribute_name, element_category, attribute_category, template_name): - result["checked"] = True + # result["checked"] = True attributes.append(result) attributes = duplicate_linked_attributes(attributes) From 414610cefb5ee53e01ef53fbe8608713587e3a5e Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 4 Feb 2026 10:30:01 +0100 Subject: [PATCH 055/156] activate the SSL switch --- custom-recipes/pi-system-af-tree/recipe.py | 1 + resource/browse_af_tree.py | 3 +-- resource/pi-system_af-explorer.html | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index ed4c7fdd..a2d954f4 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -42,6 +42,7 @@ def next_tree_item(tree_data): logger.info("Initialization with config config={}".format(logger.filter_secrets(config))) auth_type, username, password, server_url, is_ssl_check_disabled = get_credentials(config) +is_ssl_check_disabled = config.get("is_ssl_check_disabled", False) # Because no advanced parameter switch network_timer = PerformanceTimer() processing_timer = PerformanceTimer() diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 74118d78..18bb28a8 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -14,7 +14,6 @@ def do(payload, config, plugin_config, inputs): input_dataset = dataiku.Dataset(input_dataset_name) input_tree = input_dataset.get_dataframe(infer_with_pandas=False) - config["is_ssl_check_disabled"] = True if "config" in config: config = config.get("config") if "credentials" not in config: @@ -23,6 +22,7 @@ def do(payload, config, plugin_config, inputs): return {"choices": [{"label": "Pick a credential"}]} auth_type, username, password, server_url, is_ssl_check_disabled, credential_error = get_credentials(config, can_raise=False) + is_ssl_check_disabled = config.get("is_ssl_check_disabled", False) # Because no advanced parameter switch if credential_error: return build_select_choices(credential_error) @@ -40,7 +40,6 @@ def do(payload, config, plugin_config, inputs): return build_select_choices("Fill in the server address") is_debug_mode = check_debug_mode(config) - is_ssl_check_disabled = True client = OSIsoftClient(server_url, auth_type, username, password, is_ssl_check_disabled=is_ssl_check_disabled, is_debug_mode=is_debug_mode) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 7acdc25b..7c412570 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -14,6 +14,9 @@ inline-plugin-params="inlinePluginParams" error-scope="errorScope" required>
          +
          + Disable SSL check +
          @@ -27,13 +30,10 @@
          -
          -
          - Use SSL -
          -
          - Disable SSL check -
          +
          @@ -30,7 +31,8 @@
          -
          @@ -51,22 +53,37 @@
          -
          Elements
          -
          - - - - +
          +
            +
          • + Element +
          • +
          • + Template +
          • +
          +
          + + + +
          +
          + +
          + - + +
          + +
          + - - {{attribute.title}} - {{attribute.description}} - - - + + + + + + {{attribute.title}} + {{attribute.description}} + + +
          -
          - - +
          \ No newline at end of file From 0b77bae405b380b68cec007bbf4bae6f5e6062b0 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 5 Feb 2026 09:12:17 +0100 Subject: [PATCH 057/156] update get_templates_from_db to retrieve data in tree --- python-lib/osisoft_plugin_common.py | 2 +- resource/browse_af_tree.py | 46 ++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 81692a09..4728423f 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -640,7 +640,7 @@ def add(self, start_time, end_time, interval): def get_item_details(item): KEYS_TO_CHECK = { "Name": "title", "TemplateName": "template_name", "CategoryNames": "category_names", "Description": "description", - "HasChildren": "has_children", "Path": "path", "WebId": "id", "checked": "checked" + "HasChildren": "has_children", "Path": "path", "WebId": "id", "checked": "checked", "BaseTemplate": "BaseTemplate" } # should we stick to python naming convention or keep pi's ones throughout ? details = {} for key_to_check in KEYS_TO_CHECK: diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 18bb28a8..7a3d50c1 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -53,7 +53,8 @@ def do(payload, config, plugin_config, inputs): if method == "get_templates_from_db": database_name = config.get("database_name") parent = payload.get("parent", {}) - ret = get_items_from_db(client, parent, "ElementTemplates", database_name=database_name) + # ret = get_items_from_db(client, parent, "ElementTemplates", database_name=database_name) + ret = get_template_hierarchy_from_db(client, parent, database_name=database_name) return ret if method == "get_attribute_categories_from_db": database_name = config.get("database_name") @@ -181,19 +182,50 @@ def get_children_from_db(client, parent_node, database_name=None): if child.get("has_children"): child["children"] = [] children.append(child) - return {"choices": children} -# method2: -# we dig, but this time it's index[token name], and we store as we go in the child, with the real data indexed in a list and just the rank pointing to it -# to build the final tree, we browse the index, get the index data, rebuild the struct from there -# Tree class ? put(path, data), get(path, data) + +def get_template_hierarchy_from_db(client, parent_node, database_name=None): + if isinstance(parent_node, dict): + url = parent_node.get("url", database_name) + else: + url = parent_node + default_choice = {"title": "-- Any --"} + this_node = next(client.get_next_item_from_url(url)) + links = this_node.get("Links", {}) + element_templates_url = links.get("ElementTemplates") + children = [default_choice] + rebuilt_tree = [] + if element_templates_url: + element_templates = client.get_next_item_from_url(element_templates_url) + for element_template in element_templates: + child = get_item_details(element_template) + child["type"] = "template" + child["children"] = [] + children.append(child) + rebuilt_tree = nest_children(children) + return {"choices": rebuilt_tree} + + +def nest_children(items): + name_to_item = {item["title"]: item for item in items} + tree = [] + for item in items: + parent_name = item.get("BaseTemplate") + if parent_name is None or parent_name not in name_to_item: + tree.append(item) + else: + parent = name_to_item[parent_name] + if "children" not in parent: + parent["children"] = [] + parent["children"].append(item) + return tree def rebuild_tree(client, items, root_tree=None): # builds an active tree containing all the items and their parent up to the root tree = Tree(root_tree=root_tree) - tree.print() + # tree.print() while len(items) > 1: item = items.pop() if item is None: From ef361ddaab0fcfee99e79170cf04fb66dfa3bcb3 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 9 Feb 2026 15:46:19 +0100 Subject: [PATCH 058/156] *fix collapse/expand on click *add an array of urls for clicked nodes --- js/pi-system_treecontroller.js | 156 +++++++++++++++++----------- resource/pi-system_af-explorer.html | 8 +- 2 files changed, 101 insertions(+), 63 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index df033fa4..e649b311 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -29,6 +29,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.treeData = TreeDataService.getTreeData(); $scope.config.attributeList = $scope.config.attributeList || []; $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; + $scope.config.clickedNodes = $scope.config.clickedNodes || []; $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); @@ -218,79 +219,112 @@ app.controller('AfExplorerFormCtrl', [ }]); -app.directive('treeNode', function () { - return { - restrict: 'E', - scope: { - node: '=', // mutable object (tree structure changes) - getChildrenFromDB: '<', // function reference - displayAttributes: '<', // function reference - config: '<' // read/write nested properties only - }, - - template: ` -
          - - - - - - -
          - - {{ node.title }} - -
          -
          -
            +app.component('treeNode', { + bindings: { + node: '=', + getChildrenFromDb: '<', + displayAttributes: '<', + config: '<', + clickedNodes: '=' + }, -
          • + controllerAs: 'ctrl', - - + controller: function () { + const ctrl = this; -
          • + ctrl.toggleExpand = function (node, $event) { + if ($event) { + $event.stopPropagation(); + } -
          - `, + node.expanded = !node.expanded; - link: function (scope) { + if (node.expanded && (!node.children || !node.children.length)) { + // Call function reference directly + ctrl.getChildrenFromDb(node); + } + }; - // Toggle expansion + lazy loading - scope.toggleExpand = function (node) { - node.expanded = !node.expanded; + ctrl.onNodeClick = function (node) { + const index = ctrl.clickedNodes.indexOf(node.url); + if (index > -1) { + ctrl.clickedNodes.splice(index, 1); + } else { + ctrl.clickedNodes.push(node.url); + } + console.log('Clicked nodes:', ctrl.clickedNodes); - if (node.expanded && (!node.children || !node.children.length)) { - scope.getChildrenFromDB(node); - } - }; + ctrl.displayAttributes(node); + }; + ctrl.hasAttributes = function (node) { + if ( + !Array.isArray(ctrl.config?.attributeList) || + !ctrl.config.attributeList.length + ) { + return false; + } - scope.hasAttributes = function (node) { - if (!Array.isArray(scope.config.attributeList) || - !scope.config.attributeList.length) { - return false; - } + return ctrl.config.attributeList.some(attr => { + const expected = node.title + '|' + attr.title; + return attr.path.endsWith(expected); + }); + }; - return scope.config.attributeList.some(attr => { - const expected = node.title + "|" + attr.title; - return attr.path.endsWith(expected); - }); - }; + ctrl.isNodeClicked = function (node) { + return ctrl.clickedNodes.includes(node.url); + }; + }, + + template: ` +
          + + + + + + +
          + + {{ ctrl.node.title }} + +
          - } - }; +
          + +
            +
          • + + +
          • +
          + ` }); + diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index a5ef7d4d..24b1a4cd 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -86,8 +86,12 @@
          • - +
          From ff1364812f22120362027c067fd59c7edbcaf378 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 10 Feb 2026 09:02:20 +0100 Subject: [PATCH 059/156] make expanding chevron appears only if the node has children --- js/pi-system_treecontroller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index e649b311..2bdb9c3f 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -286,8 +286,8 @@ app.component('treeNode', { ng-click="ctrl.toggleExpand(ctrl.node, $event)" style="cursor: pointer;" > - - + +
          From 0102b072bfe343765f01cdb46e3740bd43c14047 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Wed, 11 Feb 2026 14:38:46 +0100 Subject: [PATCH 060/156] search based on selected nodes, add maxCounts --- js/pi-system_treecontroller.js | 18 +++- python-lib/osisoft_client.py | 142 ++++++++++++++-------------- resource/browse_af_tree.py | 27 +++++- resource/pi-system_af-explorer.html | 20 +++- 4 files changed, 131 insertions(+), 76 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 2bdb9c3f..d2596f82 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -33,7 +33,23 @@ app.controller('AfExplorerFormCtrl', [ $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); + $scope.onAdvancedToggle = function () { + if (!$scope.config.show_advanced_parameters) { + $scope.config.is_ssl_check_disabled = false; + $scope.config.elements_max_count = null; + $scope.config.attributes_max_count = null; + } else { + if ($scope.config.elements_max_count === null || $scope.config.elements_max_count === undefined || $scope.config.elements_max_count === "") { + $scope.config.elements_max_count = 100; + } + if ($scope.config.attributes_max_count === null || $scope.config.attributes_max_count === undefined || $scope.config.attributes_max_count === "") { + $scope.config.attributes_max_count = 100; + } + } + }; + $scope.init = function () { + $scope.config.show_advanced_parameters = $scope.config.show_advanced_parameters || false; DataikuAPI.plugins.listAccessiblePresets('pi-system', $stateParams.projectKey, 'basic-auth').success(function (data) { $scope.inlineParams = data.inlineParams; $scope.inlinePluginParams = data.inlinePluginParams; @@ -57,6 +73,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.showTreeData = true; } $scope.config.template = $scope.config.template || "-- Any --"; + $scope.onAdvancedToggle(); }; $scope.getServers = function () { @@ -327,4 +344,3 @@ app.component('treeNode', { ` }); - diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 7677f85c..8f9b083d 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -782,7 +782,18 @@ def search_elements(self, database_webid, name=None, description=None, category= params["startIndex"] = start_index json_response = self.get(url=url, headers=headers, params=params) - def batched_search(self, database, element_name, attribute_name, element_category, attribute_category, template): + def batched_search(self, database, element_name, attribute_name, element_category, + attribute_category, template, restrict_to_elements, + elements_max_count=None, attributes_max_count=None): + elements_query = { + "templateName": template, + "categoryName": element_category, + "nameFilter": element_name, + "searchFullHierarchy": "true", + "associations": "Paths" + } + if elements_max_count: + elements_query["maxCount"] = elements_max_count attribute_query = { "searchFullHierarchy": "true", "associations": "Paths" @@ -791,80 +802,67 @@ def batched_search(self, database, element_name, attribute_name, element_categor attribute_query["nameFilter"] = attribute_name if attribute_category: attribute_query["categoryName"] = attribute_category + if attributes_max_count: + attribute_query["maxCount"] = attributes_max_count elements_url = "{}/elements".format(database) - print("ALX:fetching direct from {}".format(elements_url)) - request_body = { - "elements": { - "Method": "GET", - "Resource": "{}{}".format( - elements_url, - build_query_string("", { - "templateName": template, - "categoryName": element_category, - "nameFilter": element_name, - "searchFullHierarchy": "true", - "associations": "Paths" - } + if not restrict_to_elements: + request_body = { + "elements": { + "Method": "GET", + "Resource": "{}{}".format( + elements_url, + build_query_string("", elements_query) ) - ) - }, - "attributes": { - "Method": "GET", - "RequestTemplate": { - "Resource": "{{0}}{}".format( - build_query_string("", attribute_query) - ) #?searchFullHierarchy=true&selectedFields=Items.WebId;Items.Path" - }, # build_attribute_query(attribute_name, attribute_category) - "ParentIds": ["elements"], - "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] + }, + "attributes": { + "Method": "GET", + "RequestTemplate": { + "Resource": "{{0}}{}".format( + build_query_string("", attribute_query) + ) + }, + "ParentIds": ["elements"], + "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] + } } - } - # request_body = { - # "database": { - # "Method": "GET", - # "Resource": "https://osisoft/piwebapi/assetdatabases?path=\\\\osisoft-pi-serv\\Well&selectedFields=WebId;Path;Links" - # }, - # "elements": { - # "Method": "GET", - # "Resource": "{{0}}{}".format( - # build_query_string("", { - # "templateName": template, - # "categoryName": element_category, - # "nameFilter": element_name, - # "searchFullHierarchy": "true", - # "associations": "Paths" - # } - # ) - # ), - # "ParentIds": ["database"], - # "Parameters": ["$.database.Content.Links.Elements"] - # }, - # "attributes": { - # "Method": "GET", - # "RequestTemplate": { - # "Resource": "{{0}}{}".format( - # build_query_string("", attribute_query) - # ) #?searchFullHierarchy=true&selectedFields=Items.WebId;Items.Path" - # }, # build_attribute_query(attribute_name, attribute_category) - # "ParentIds": ["elements"], - # "Parameters": ["$.elements.Content.Items[*].Links.Attributes"] - # } - # } - url = self.endpoint.get_batch_endpoint() - headers = OSIsoftConstants.WRITE_HEADERS - response = self.post(url, headers=headers, data=request_body, params={}) - json_response = response.json() - attributes = json_response.get("attributes", {}) - attributes_content = attributes.get("Content", {}) - if not isinstance(attributes_content, dict): - # the search returned nothing - return - attributes_content_items = attributes_content.get("Items", []) - for attributes_content_item in attributes_content_items: - content = attributes_content_item.get("Content", {}) - sub_items = content.get("Items", []) - for sub_item in sub_items: - yield sub_item + url = self.endpoint.get_batch_endpoint() + headers = OSIsoftConstants.WRITE_HEADERS + response = self.post(url, headers=headers, data=request_body, params={}) + json_response = response.json() + attributes = json_response.get("attributes", {}) + attributes_content = attributes.get("Content", {}) + if not isinstance(attributes_content, dict): + # the search returned nothing + return + attributes_content_items = attributes_content.get("Items", []) + for attributes_content_item in attributes_content_items: + content = attributes_content_item.get("Content", {}) + sub_items = content.get("Items", []) + for sub_item in sub_items: + yield sub_item + else: + count = 1 + request_body = {} + for restrict_to_element in restrict_to_elements: + job_tag = "J_{}".format(count) + request_body[job_tag] = { + "Method": "GET", + "Resource": "{}/attributes{}".format( + restrict_to_element, + build_query_string("", attribute_query) + ) + } + count = 1 + url = self.endpoint.get_batch_endpoint() + headers = OSIsoftConstants.WRITE_HEADERS + response = self.post(url, headers=headers, data=request_body, params={}) + json_response = response.json() + for job_tag in json_response: + job_result = json_response.get(job_tag) + content = job_result.get("Content", {}) + sub_items = content.get("Items", []) + for sub_item in sub_items: + yield sub_item def build_element_query(self, **kwargs): element_query_keys = { diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 7a3d50c1..70a914db 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -69,6 +69,7 @@ def do(payload, config, plugin_config, inputs): if method == "do_search": template_name = config.get("template", None) category_name = config.get("element_category", None) + clicked_nodes = config.get("clickedNodes", []) if template_name == "-- Any --": template_name = None if category_name == "-- Any --": @@ -88,9 +89,12 @@ def do(payload, config, plugin_config, inputs): attributes = [] # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw database_webid = database_name.split("/")[-1] + elements_max_count, attributes_max_count = get_max_counts(config) attributes = [] - for result in client.batched_search(database_name, element_name, attribute_name, element_category, attribute_category, template_name): + for result in client.batched_search(database_name, element_name, attribute_name, + element_category, attribute_category, template_name, clicked_nodes, + elements_max_count=elements_max_count, attributes_max_count=attributes_max_count): # result["checked"] = True attributes.append(result) @@ -302,3 +306,24 @@ def set_as_selected(items): def update_item(item, tree): elements_paths_tokens, attributes_paths_tokens = path_to_list(item.get("path")) tree.put(elements_paths_tokens + attributes_paths_tokens, item) + + +def get_max_counts(config): + show_advanced_parameters = config.get("show_advanced_parameters", False) + if not show_advanced_parameters: + return 100, 100 + + def parse_max_count(value, default): + if value is None or value == "": + return default + try: + value = int(value) + except (TypeError, ValueError): + return default + if value <= 0: + return None + return value + + elements_max_count = parse_max_count(config.get("elements_max_count"), 100) + attributes_max_count = parse_max_count(config.get("attributes_max_count"), 100) + return elements_max_count, attributes_max_count diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 24b1a4cd..6fbc1f52 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -15,9 +15,25 @@
          + +
          + +
          + +
          +
          +
          + +
          + +
          +
          @@ -139,4 +155,4 @@
          -
        \ No newline at end of file +
      From bfa1762ea285029d3b76079c919d679f612b31b3 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 17 Feb 2026 15:24:11 +0100 Subject: [PATCH 061/156] add network stats --- resource/browse_af_tree.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 70a914db..82080885 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -1,8 +1,11 @@ from osisoft_client import OSIsoftClient +from safe_logger import SafeLogger from osisoft_plugin_common import get_credentials, build_select_choices, check_debug_mode -from osisoft_plugin_common import get_item_details, Tree, recursive_tree_rebuild +from osisoft_plugin_common import get_item_details, Tree, recursive_tree_rebuild, PerformanceTimer import dataiku +logger = SafeLogger("PI System plugin", ["user", "password"]) + def do(payload, config, plugin_config, inputs): input_tree = None @@ -41,7 +44,13 @@ def do(payload, config, plugin_config, inputs): is_debug_mode = check_debug_mode(config) - client = OSIsoftClient(server_url, auth_type, username, password, is_ssl_check_disabled=is_ssl_check_disabled, is_debug_mode=is_debug_mode) + network_timer = PerformanceTimer() + + client = OSIsoftClient( + server_url, auth_type, username, password, + is_ssl_check_disabled=is_ssl_check_disabled, is_debug_mode=is_debug_mode, + network_timer=network_timer + ) method = payload.get("method") if method == "get_query_catalogs": @@ -105,6 +114,7 @@ def do(payload, config, plugin_config, inputs): items.append(item) attributesCopy = items.copy() rebuilt_tree = rebuild_tree(client, items, root_tree) + logger.info("Search network timer:{}".format(network_timer.get_report())) return {"choices": rebuilt_tree, "attributes": attributesCopy} parameter_name = payload.get("parameterName") From 33c3b612ebebfccb6b61c59ae52a3d38fc1ce85c Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 18 Feb 2026 14:19:01 +0100 Subject: [PATCH 062/156] * remove multiselect *template tree --- js/pi-system_treecontroller.js | 71 ++++++++++++++++++++++++---------- resource/browse_af_tree.py | 2 +- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index d2596f82..c2e2841d 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -3,6 +3,7 @@ var app = angular.module('piSystemTreeApp.module', []); app.service('TreeDataService', function () { // This will store the shared tree data this.treeData = []; + this.templateTreeData = []; // Optional: helper methods this.setTreeData = function (data) { @@ -12,6 +13,14 @@ app.service('TreeDataService', function () { this.getTreeData = function () { return this.treeData; }; + + this.setTemplateTreeData = function (data) { + this.templateTreeData = data; + }; + + this.getTemplateTreeData = function () { + return this.templateTreeData; + }; }); app.controller('AfExplorerFormCtrl', [ @@ -27,6 +36,7 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.treeData = TreeDataService.getTreeData(); + $scope.templateTreeData = TreeDataService.getTemplateTreeData(); $scope.config.attributeList = $scope.config.attributeList || []; $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; $scope.config.clickedNodes = $scope.config.clickedNodes || []; @@ -71,6 +81,7 @@ app.controller('AfExplorerFormCtrl', [ if ($scope.authConfigured() === true) { $scope.authSectionVisible = false; $scope.showTreeData = true; + $scope.showTemplateTreeData = true; } $scope.config.template = $scope.config.template || "-- Any --"; $scope.onAdvancedToggle(); @@ -98,10 +109,9 @@ app.controller('AfExplorerFormCtrl', [ return $scope.hasPreset() && $scope.config.database_name && $scope.config.database_name.length > 0 && $scope.config.server_name && $scope.config.server_name.length > 0; } $scope.explore = function () { - console.log("coucou"); if ($scope.authConfigured()) { - console.log("here"); $scope.showTreeData = true; + $scope.showTemplateTreeData = true; } }; @@ -135,9 +145,25 @@ app.controller('AfExplorerFormCtrl', [ }); } + $scope.getTemplateHierarchyFromDB = function (item) { + console.log("ALX:gthfd:" + JSON.stringify(item)); + return $scope.callPythonDo({ method: "get_template_hierarchy_from_db", parent: item }) + .then(function (data) { + console.log("ALX:data1=" + JSON.stringify(data)); + item.children = data.choices; + item.children.forEach(child => { + child.expanded = false; + }); + console.log(item); + return item; + }); + } + $scope.getTemplatesFromDB = function () { - $scope.callPythonDo({ method: "get_templates_from_db" }).then(function (data) { + $scope.callPythonDo({ method: "get_templates_from_db" }).then(function (data) { $scope.config.templates = data.choices; + TreeDataService.setTemplateTreeData(data.choices); + $scope.config.templateTreeData = TreeDataService.getTemplateTreeData(); }); } @@ -206,12 +232,18 @@ app.controller('AfExplorerFormCtrl', [ $scope.displayAttributes = function (node) { - if (!node.children || node.children.length === 0) { - $scope.getChildrenFromDB(node).then(newNode => { - processNode(newNode); - }); - } else { + + if (node.type === "element") { + + $scope.getChildrenFromDB(node).then(newNode => { + processNode(newNode); + }); + } else if (node.type === "template") { + $scope.config.template = node.title; + $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); + } + } else { processNode(node); }; } @@ -266,15 +298,14 @@ app.component('treeNode', { }; ctrl.onNodeClick = function (node) { - const index = ctrl.clickedNodes.indexOf(node.url); + ctrl.config.clickedNodes = []; // TODO remove when you want to use multiselect + ctrl.displayAttributes(node); + const index = ctrl.config.clickedNodes.indexOf(node.url); if (index > -1) { - ctrl.clickedNodes.splice(index, 1); + ctrl.config.clickedNodes.splice(index, 1); } else { - ctrl.clickedNodes.push(node.url); + ctrl.config.clickedNodes.push(node.url); } - console.log('Clicked nodes:', ctrl.clickedNodes); - - ctrl.displayAttributes(node); }; ctrl.hasAttributes = function (node) { @@ -292,7 +323,7 @@ app.component('treeNode', { }; ctrl.isNodeClicked = function (node) { - return ctrl.clickedNodes.includes(node.url); + return ctrl.config.clickedNodes.includes(node.url); }; }, @@ -303,13 +334,13 @@ app.component('treeNode', { ng-click="ctrl.toggleExpand(ctrl.node, $event)" style="cursor: pointer;" > - - + +
      diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 82080885..5050d71e 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -204,7 +204,7 @@ def get_template_hierarchy_from_db(client, parent_node, database_name=None): url = parent_node.get("url", database_name) else: url = parent_node - default_choice = {"title": "-- Any --"} + default_choice = {"title": "-- Any --", "id:": ""} this_node = next(client.get_next_item_from_url(url)) links = this_node.get("Links", {}) element_templates_url = links.get("ElementTemplates") From 80bf9228fa000b4c2a3c08df5d33ec74c23464c9 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 18 Feb 2026 14:19:16 +0100 Subject: [PATCH 063/156] template tree part2 --- resource/pi-system_af-explorer.html | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 6fbc1f52..e5143cf1 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -82,11 +82,7 @@ - -
      @@ -100,7 +96,7 @@ ng-change="doSearch(config.element_name, config.attribute_name)" /> -
        +
        +
          +
        • + + +
        • +
        From 5e3955e77cd7f038b9a1565762a809fe9425c806 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 18 Feb 2026 15:26:33 +0100 Subject: [PATCH 064/156] *save active tab state (element/template) *add select/unselect all *separate template search from element search --- js/pi-system_treecontroller.js | 63 +++++++++++++++++++---------- resource/pi-system_af-explorer.html | 17 +++++--- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c2e2841d..16559d3b 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -60,6 +60,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.init = function () { $scope.config.show_advanced_parameters = $scope.config.show_advanced_parameters || false; + $scope.config.activeTab = $scope.config.activeTab || 'element'; DataikuAPI.plugins.listAccessiblePresets('pi-system', $stateParams.projectKey, 'basic-auth').success(function (data) { $scope.inlineParams = data.inlineParams; $scope.inlinePluginParams = data.inlinePluginParams; @@ -167,10 +168,8 @@ app.controller('AfExplorerFormCtrl', [ }); } - $scope.activeTab = 'element'; // tab par défaut - $scope.setTab = function(tab) { - $scope.activeTab = tab; + $scope.config.activeTab = tab; }; $scope.getCategoriesFromDB = function () { @@ -209,37 +208,59 @@ app.controller('AfExplorerFormCtrl', [ ); }; - $scope.updateAttributeToOutput = function (attribute) { - if (attribute.checked && $scope.config.selectedAttributes.includes(attribute)) { - $scope.config.selectedAttributes = $scope.config.selectedAttributes.filter(attr => attr.path !== attribute.path); + $scope.toggleSelectAllAttributes = function () { + if ($scope.config.selectAllAttributes) { + $scope.config.selectedAttributes = [...$scope.config.attributeList]; + $scope.config.attributeList.forEach(attr => attr.checked = true); + } else { + $scope.config.selectedAttributes = []; + $scope.config.attributeList.forEach(attr => attr.checked = false); } - else { - console.log("Adding attribute to output:", attribute); + } - if (!$scope.config || !$scope.config.attributeList || $scope.config.selectedAttributes.includes(attribute)) { - return; - } - const attrInConfig = $scope.config.attributeList.find(attr => attr.path === attribute.path); + $scope.updateAttributeToOutput = function (attribute) { + if (!$scope.config || !$scope.config.attributeList) return; + + const selectedAttributes = $scope.config.selectedAttributes; + const attributeList = $scope.config.attributeList; + + const index = selectedAttributes.findIndex(attr => attr.path === attribute.path); + + if (index !== -1) { + selectedAttributes.splice(index, 1); + + const attrInConfig = attributeList.find(attr => attr.path === attribute.path); + if (attrInConfig) attrInConfig.checked = false; + + $scope.config.selectAllAttributes = false; + return; + } + + const attrInConfig = attributeList.find(attr => attr.path === attribute.path); + + if (!attrInConfig) { + console.warn("Attribute not found in config:", attribute.path); + return; + } + + selectedAttributes.push(attribute); + attrInConfig.checked = true; + + $scope.config.selectAllAttributes = selectedAttributes.length === attributeList.length; +}; - if (attrInConfig) { - $scope.config.selectedAttributes.push(attribute); - attrInConfig.checked = true; - } else { - console.warn("Attribute not found in config:", attribute.path); - } - } - }; $scope.displayAttributes = function (node) { if (!node.children || node.children.length === 0) { if (node.type === "element") { - + $scope.config.template = "-- Any --"; $scope.getChildrenFromDB(node).then(newNode => { processNode(newNode); }); } else if (node.type === "template") { + $scope.config.element_name = "*"; $scope.config.template = node.title; $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); } diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index e5143cf1..043177f5 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -71,18 +71,18 @@
          -
        • +
        • Element
        • -
        • +
        • Template
        - -
        @@ -96,7 +96,7 @@ ng-change="doSearch(config.element_name, config.attribute_name)" />
        -
          +
          -
            +
            • + + + + + Date: Wed, 18 Feb 2026 16:15:41 +0100 Subject: [PATCH 065/156] enhance configuration UI --- resource/pi-system_af-explorer.css | 5 + resource/pi-system_af-explorer.html | 248 ++++++++++++++-------------- 2 files changed, 129 insertions(+), 124 deletions(-) diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index c03843a1..94d138da 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -28,6 +28,11 @@ border: 1px solid #ccc; padding: 10px; border-radius: 5px; } +.pi-system-config--mandatory { + display: flex; + gap: 32px; +} + .custom-table { border-collapse: collapse; width: 100%; diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 043177f5..8d25db13 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -5,54 +5,65 @@ Welcome to Pi System Plugin
              -
              - -
              -
              +
              +
              + +
              +
              +
              - - -
              - -
              - +
              + +
              +
              -
              - -
              - +
              + +
              +
              -
              - +
              +
              - +
              -
              - +
              +
              + +
              + +
              +
              +
              +
              - +
              +
              + +
              + +
              +
              +
              @@ -65,106 +76,95 @@ ng-options="dbn.value as dbn.label for dbn in [config.treeData]" ng-init="initializeTree();">
              -
              -
              -
              -
              -
                -
              • - Element -
              • -
              • - Template -
              • -
              -
              - - -
              +
              +
              +
              +
                +
              • + Element +
              • +
              • + Template +
              • +
              +
              + +
              +
              + +
              + +
              + +
              - - + + + + + + + + -
              - -
              -
              SelectTitleDescription
              - - - - - - - - - - - - - - - - - - -
              SelectTitleDescription
              - -
              - - {{attribute.title}}{{attribute.description}}
              -
              + + + + + + + + + + + {{attribute.title}} + {{attribute.description}} + + +
              -
              +
              \ No newline at end of file From 1d95e19b3814685c3ea19fc770025651d5772869 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 19 Feb 2026 10:22:59 +0100 Subject: [PATCH 066/156] fix has_children use for both elements and templates trees --- js/pi-system_treecontroller.js | 4 ++-- resource/browse_af_tree.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 16559d3b..28e9883b 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -355,8 +355,8 @@ app.component('treeNode', { ng-click="ctrl.toggleExpand(ctrl.node, $event)" style="cursor: pointer;" > - - + +
              diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 5050d71e..670a90f1 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -233,6 +233,7 @@ def nest_children(items): if "children" not in parent: parent["children"] = [] parent["children"].append(item) + parent["has_children"] = True return tree From 3e26980612dfc3146604f3e481f4748345512b1b Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 11:37:22 +0100 Subject: [PATCH 067/156] remove recipe input dataset --- custom-recipes/pi-system-af-tree/recipe.json | 9 --------- custom-recipes/pi-system-af-tree/recipe.py | 3 +-- resource/browse_af_tree.py | 9 --------- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.json b/custom-recipes/pi-system-af-tree/recipe.json index 5b917a43..15baeae7 100644 --- a/custom-recipes/pi-system-af-tree/recipe.json +++ b/custom-recipes/pi-system-af-tree/recipe.json @@ -5,17 +5,8 @@ "icon": "icon-pi-system icon-cogs" }, "kind": "PYTHON", - "selectableFromDataset": "input_dataset", "paramsPythonSetup": "browse_af_tree.py", "inputRoles": [ - { - "name": "input_dataset", - "label": "Dataset containing paths or tags", - "description": "", - "arity": "UNARY", - "required": false, - "acceptsDataset": true - } ], "outputRoles": [ diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index a2d954f4..8774569a 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -1,5 +1,5 @@ import dataiku -from dataiku.customrecipe import get_input_names_for_role, get_recipe_config, get_output_names_for_role +from dataiku.customrecipe import get_recipe_config, get_output_names_for_role from safe_logger import SafeLogger from osisoft_plugin_common import ( get_credentials, PerformanceTimer @@ -34,7 +34,6 @@ def next_tree_item(tree_data): yield item -input_dataset = get_input_names_for_role('input_dataset') output_names_stats = get_output_names_for_role('api_output') config = get_recipe_config() tree_data = config.get("treeData", []) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 670a90f1..5a4dca05 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -8,15 +8,6 @@ def do(payload, config, plugin_config, inputs): - input_tree = None - if len(inputs) > 0: - input_item = inputs[0] - input_type = input_item.get("type") - if input_type == "DATASET": - input_dataset_name = input_item.get("fullName") - input_dataset = dataiku.Dataset(input_dataset_name) - input_tree = input_dataset.get_dataframe(infer_with_pandas=False) - if "config" in config: config = config.get("config") if "credentials" not in config: From 08ba22a8088fb5c5d2c682085ed45f1cce9af8c8 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 12:13:29 +0100 Subject: [PATCH 068/156] update datas on database selection change --- js/pi-system_treecontroller.js | 16 +++++++++++++++- resource/pi-system_af-explorer.html | 10 ++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 28e9883b..2510d31c 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -120,9 +120,14 @@ app.controller('AfExplorerFormCtrl', [ return $scope.config.credentials && $scope.config.credentials.mode && $scope.config.credentials.mode !== 'NONE' && $scope.config.credentials.name } + $scope.cleanTree = function () { + $scope.config.treeData = []; + $scope.config.clickedNodes = []; + $scope.config.attributeList = []; + } + $scope.initializeTree = function () { console.log("initialization: "); - console.log($scope.config.treeData); if (!$scope.config.treeData || $scope.config.treeData.length === 0) { $scope.callPythonDo({ method: "get_children_from_db", parent: $scope.config.database_name }).then(function (data) { console.log("ALX:data2=" + JSON.stringify(data)); @@ -130,8 +135,17 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.treeData = TreeDataService.getTreeData(); }); } + }; + $scope.updateDatas = function () { + $scope.cleanTree(); + $scope.initializeTree(); + $scope.getTemplatesFromDB(); + $scope.getCategoriesFromDB(); + $scope.showTreeData = false; + } + $scope.getChildrenFromDB = function (item) { console.log("ALX:gcfd:" + JSON.stringify(item)); return $scope.callPythonDo({ method: "get_children_from_db", parent: item }) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 8d25db13..74379d22 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -29,10 +29,12 @@
              - +
              From 7acfa0d5c9cc1252336f03e4d7b86f4730237e6e Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 12:49:08 +0100 Subject: [PATCH 069/156] fix element unselection --- js/pi-system_treecontroller.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 2510d31c..c38d429b 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -332,16 +332,20 @@ app.component('treeNode', { } }; - ctrl.onNodeClick = function (node) { - ctrl.config.clickedNodes = []; // TODO remove when you want to use multiselect - ctrl.displayAttributes(node); - const index = ctrl.config.clickedNodes.indexOf(node.url); - if (index > -1) { - ctrl.config.clickedNodes.splice(index, 1); - } else { - ctrl.config.clickedNodes.push(node.url); - } - }; + + ctrl.onNodeClick = function (node) { + const url = node.url; + const clicked = ctrl.config.clickedNodes; + + if (clicked[0] === url) { + clicked.length = 0; + } else { + clicked.length = 0; // todo - remove to make the multiselect + clicked.push(url); + } + + ctrl.displayAttributes(node); + }; ctrl.hasAttributes = function (node) { if ( From cca26d1fb4ab96f143104a14fe67e8d9fec3b3b4 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 13:10:09 +0100 Subject: [PATCH 070/156] fix template selection --- js/pi-system_treecontroller.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c38d429b..247d21e2 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -331,21 +331,17 @@ app.component('treeNode', { ctrl.getChildrenFromDb(node); } }; - - - ctrl.onNodeClick = function (node) { - const url = node.url; - const clicked = ctrl.config.clickedNodes; - - if (clicked[0] === url) { - clicked.length = 0; - } else { - clicked.length = 0; // todo - remove to make the multiselect - clicked.push(url); - } - - ctrl.displayAttributes(node); - }; + + ctrl.onNodeClick = function (node) { + const index = ctrl.config.clickedNodes.indexOf(node.url); + ctrl.config.clickedNodes = []; // TODO remove when you want to use multiselect + ctrl.displayAttributes(node); + if (index > -1) { + ctrl.config.clickedNodes.splice(index, 1); + } else { + ctrl.config.clickedNodes.push(node.url); + } + }; ctrl.hasAttributes = function (node) { if ( From d1023498b606b13e03bc539f9e35abbbe4ef56bd Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 13:10:18 +0100 Subject: [PATCH 071/156] *add label for "select all" checkbox *reset selectAllAttributes state when changing element * --- js/pi-system_treecontroller.js | 1 + resource/pi-system_af-explorer.html | 1 + 2 files changed, 2 insertions(+) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 247d21e2..1a06dd5f 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -266,6 +266,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.displayAttributes = function (node) { + $scope.config.selectAllAttributes = false; if (!node.children || node.children.length === 0) { if (node.type === "element") { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 74379d22..5b66f345 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -156,6 +156,7 @@ + Select All From 1b67c5af232f93c3a3ae67110eaf9d060cfa9d6e Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Feb 2026 13:15:20 +0100 Subject: [PATCH 072/156] fix output columns content --- js/pi-system_treecontroller.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 1a06dd5f..25ccea82 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -292,11 +292,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectedAttributes = []; node.children.forEach(child => { if (child.type === "attribute") { - $scope.config.attributeList.push({ - "title": child.title, - "path": child.path, - "description": child.description - }); + $scope.config.attributeList.push(child); } }); } From 8b92413984013ecab9743607b00260d85ab5f3b3 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 24 Feb 2026 16:04:52 +0100 Subject: [PATCH 073/156] turn category_names into a string column --- custom-recipes/pi-system-af-tree/recipe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 8774569a..0c74051b 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -51,7 +51,7 @@ def next_tree_item(tree_data): schema = [ {'name': 'title', 'type': 'string'}, {'name': 'template_name', 'type': 'string'}, - {'name': 'category_names', 'type': 'array'}, + {'name': 'category_names', 'type': 'string'}, {'name': 'path', 'type': 'string'}, {'name': 'id', 'type': 'string'}, {'name': 'url', 'type': 'string'}, From a3b1429c86b062e7eb9ffe67ec3f518e05b5d14f Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 25 Feb 2026 12:27:52 +0100 Subject: [PATCH 074/156] change output dataset label --- custom-recipes/pi-system-af-tree/recipe.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.json b/custom-recipes/pi-system-af-tree/recipe.json index 15baeae7..05439d53 100644 --- a/custom-recipes/pi-system-af-tree/recipe.json +++ b/custom-recipes/pi-system-af-tree/recipe.json @@ -12,7 +12,7 @@ "outputRoles": [ { "name": "api_output", - "label": "Main output displayed name", + "label": "Attributes dataset", "description": "", "arity": "UNARY", "required": true, From 3418ee3477c54e8b7ea612b47c504bc4d9f0b32c Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Thu, 26 Feb 2026 08:01:30 +0100 Subject: [PATCH 075/156] move select all checkbox --- resource/pi-system_af-explorer.html | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 5b66f345..942a51f7 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -144,20 +144,14 @@ - + - - - - +
              Select Title Description
              - - Select All
              Date: Thu, 26 Feb 2026 11:43:46 +0100 Subject: [PATCH 076/156] v1.4.2-beta.1 --- CHANGELOG.md | 2 +- plugin.json | 2 +- python-lib/osisoft_constants.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ce03dd..d535653f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Version 1.4.1](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.4.1) - Feature release - 2025-11-26 +## [Version 1.4.2](https://github.com/dataiku/dss-plugin-pi-server/releases/tag/v1.4.2) - Feature release - 2025-11-26 - Add a AF hierarchy downloader diff --git a/plugin.json b/plugin.json index 1b199f65..4494e851 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "pi-system", - "version": "1.4.1", + "version": "1.4.2", "meta": { "label": "PI System", "description": "Retrieve data from your OSIsoft PI System servers", diff --git a/python-lib/osisoft_constants.py b/python-lib/osisoft_constants.py index ce9b74ab..ff6854b4 100644 --- a/python-lib/osisoft_constants.py +++ b/python-lib/osisoft_constants.py @@ -405,7 +405,7 @@ class OSIsoftConstants(object): "Security": "{base_url}/eventframes/{webid}/security", "SecurityEntries": "{base_url}/eventframes/{webid}/securityentries" } - PLUGIN_VERSION = "1.4.1-beta.1" + PLUGIN_VERSION = "1.4.2-beta.1" VALUE_COLUMN_SUFFIX = "_val" WEB_API_PATH = "piwebapi" WRITE_HEADERS = {'X-Requested-With': 'XmlHttpRequest'} From 861a24a3ffbcfdf9c98b12bec059a7ba42d34384 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 4 Mar 2026 12:26:40 +0100 Subject: [PATCH 077/156] remove constraint to block multi selection --- js/pi-system_treecontroller.js | 5 +++-- resource/pi-system_af-explorer.html | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 25ccea82..4314987c 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -331,13 +331,14 @@ app.component('treeNode', { ctrl.onNodeClick = function (node) { const index = ctrl.config.clickedNodes.indexOf(node.url); - ctrl.config.clickedNodes = []; // TODO remove when you want to use multiselect - ctrl.displayAttributes(node); if (index > -1) { ctrl.config.clickedNodes.splice(index, 1); } else { ctrl.config.clickedNodes.push(node.url); + ctrl.displayAttributes(node); } + + console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); }; ctrl.hasAttributes = function (node) { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 942a51f7..3690d3d1 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -159,6 +159,7 @@ {{attribute.title}} {{attribute.description}}{{attribute.path}}
              From b95f18044d6794c4f1fa8ec6df543385fb10a5d6 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 4 Mar 2026 15:26:27 +0100 Subject: [PATCH 078/156] rework UI : layouts and basic page components --- resource/pi-system_af-explorer.css | 158 ++++++++++++++++++-- resource/pi-system_af-explorer.html | 220 ++++++++++++++-------------- 2 files changed, 259 insertions(+), 119 deletions(-) diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index 94d138da..8d633c43 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -5,32 +5,126 @@ .tree-node__label--clickable{ background-color: yellow; } +.generic-class { + display: flex; + align-items: flex-start; + flex: 1 0 0; + align-self: stretch; + flex-direction: row-reverse; +} .pi-system-explorer__main { - display: grid; - grid-template-columns: 500px auto; - column-gap: 50px; - margin-top: 20px; + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; + flex: 1 0 0; + align-self: stretch; +} +.pi-system-explorer__tree-view-container { + display: flex; +width: 252px; +padding: 16px 12px; +flex-direction: column; +align-items: flex-start; +gap: 12px; +align-self: stretch; +border-right: 1px solid #BBB; +background: #FFF; } -.pi-system-explorer__tree-view, .pi-system-explorer__center-view { - border: 1px solid #ccc; padding: 10px; border-radius: 5px; + +.pi-system-explorer__tree-view { + padding: 10px; } +.pi-system-explorer__authentication { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; +} .pi-system-explorer__authentication-header { - background-color: #f0f0f0; - padding: 10px; + display: flex; + height: 32px; + padding: 0 12px; + justify-content: space-between; + align-items: center; + align-self: stretch; + border: 1px solid #999; + background: linear-gradient(0deg, rgba(242, 242,242,0.50) 100%), #FFFFFF; + /*flex-direction: column; + align-items: flex-start; + align-self: stretch; cursor: pointer; border: 1px solid #ccc; border-radius: 5px; font-weight: bold; + background-color: #f0f0f0;*/ + /*background-color: #f0f0f0; + padding: 10px; + cursor: pointer; + border: 1px solid #ccc; + border-radius: 5px; + font-weight: bold;*/ + + } .pi-system-explorer__authentication-body { - border: 1px solid #ccc; padding: 10px; border-radius: 5px; + display: flex; + padding: 16px 12px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 16px; + align-self: stretch; + border-right: 1px solid var(--Greyscale-grey-lighten-4, #999); + border-bottom: 1px solid var(--Greyscale-grey-lighten-4, #999); + border-left: 1px solid var(--Greyscale-grey-lighten-4, #999); + background: #FFF; + + + /*flex-direction: column; + align-items: flex-end; + gap: 8px; + align-self: stretch; + border: 1px solid #ccc; padding: 10px; border-radius: 5px;*/ +} + +.pi-system-explorer__authentication__submit { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + align-self: stretch; +} + +.pi-system-config--toggle-advanced, .pi-system-config--ssl-check { + display: flex; + padding-bottom: 4px; + align-items: flex-start; + gap: 8px; + width: 220px; +} + +.pi-system-config--params-advanced { + display: flex; + gap: 64px; } .pi-system-config--mandatory { display: flex; - gap: 32px; + gap: 64px; +} + +.attribute-section__search { + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; + flex: 1 0 0; + align-self: stretch; } .custom-table { @@ -59,6 +153,46 @@ background-color: #f1f7ff; } +.tree-view__tabs{ + display: flex; + align-items: center; + align-self: stretch; + border: 1px solid #BBB; +} + +.tree-view__tab { + display: flex; + align-items: flex-start; + flex: 1 0 0; + border-top: 1px solid #BBB; + border-right: 0.5px solid #BBB; + border-bottom: 1px solid #BBB; + border-left: 1px solid #BBB; + &:hover, &:active { + background-color: #E7F3FF; + } +} + +.tree-view__tab-content { + display: flex; + padding: 12px 23px; + justify-content: center; + align-items: center; + gap: 4px; + flex: 1 0 0; + color: #000; + font-family: "Source Sans Pro"; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +.tree-view__content { + gap: 8px; + display: flex; + flex-direction: column; +} .tab-container { font-family: Arial, sans-serif; } @@ -89,3 +223,7 @@ border-top: none; padding: 10px; } + +.recipe-editor-page .formbased-recipe-infozone h1, .recipe-editor-page .recipe-settings-section1 h1, .recipe-editor-page .formbased-recipe-infozone .recipe-settings-section2, .recipe-editor-page .recipe-settings-section1 .recipe-settings-section2{ + padding: 0px !important; +} diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 3690d3d1..8c2c3bbf 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -1,76 +1,67 @@ -
              -
              -
              - Welcome to Pi System Plugin -
              -
              -
              -
              - +
              +
              +
              +
              + Welcome to Pi System Plugin +
              +
              +
              -
              + +
              +
              +
              -
              -
              - -
              - +
              + +
              + +
              -
              -
              - -
              - +
              + +
              + +
              -
              -
              - +
              + + + Advanced parameters
              -
              -
              -
              - -
              - -
              -
              -
              - -
              - -
              -
              -
              - -
              - +
              + +
              Disable SSL Check
              +
              +
              Elements max count
              +
              +
              +
              +
              Attributes max count
              +
              +
              +
              -
              - -
              -
              @@ -79,68 +70,25 @@
              -
              -
              -
              -
                -
              • - Element -
              • -
              • - Template -
              • -
              -
              - - -
              -
              - -
              - -
              - + data-html="true" data-placement="right" data-toggle="tooltip" title="" placeholder="Search attribute or template name" /> -
              - -
              +
              + Filter by category + +
              + +
              +
              + Filter by category + data-html="true" data-placement="right" data-toggle="tooltip" title="" placeholder="Search attribute or template name" + ng-keydown="onSearchInputKeydown($event)" /> @@ -122,7 +123,8 @@
              + data-container="body" data-html="true" placeholder="Search element" + ng-keydown="onSearchInputKeydown($event)" /> @@ -168,4 +170,4 @@
              -
              \ No newline at end of file +
              From cbf1805e8cac0f246d30157750052617b6096f18 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Mar 2026 15:48:35 +0100 Subject: [PATCH 086/156] multiselect on click --- js/pi-system_treecontroller.js | 41 ++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c96e7f97..7ce8a3f5 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -324,8 +324,39 @@ app.controller('AfExplorerFormCtrl', [ - $scope.displayAttributes = function (node) { + function getNodeAttributePaths(node) { + if (!node || !Array.isArray(node.children)) { + return []; + } + return node.children + .filter(child => child.type === "attribute" && child.path) + .map(child => child.path); + } + + function removeNodeAttributes(node) { + const attributePaths = getNodeAttributePaths(node); + if (!attributePaths.length) { + return; + } + + $scope.config.attributeList = ($scope.config.attributeList || []).filter( + attr => !attributePaths.includes(attr.path) + ); + $scope.config.selectedAttributes = ($scope.config.selectedAttributes || []).filter( + attr => !attributePaths.includes(attr.path) + ); + $scope.config.selectAllAttributes = + $scope.config.attributeList.length > 0 && + $scope.config.selectedAttributes.length === $scope.config.attributeList.length; + } + + $scope.displayAttributes = function (node, shouldAdd = true) { $scope.config.selectAllAttributes = false; + if (!shouldAdd) { + removeNodeAttributes(node); + return; + } + if (!node.children || node.children.length === 0) { if (node.type === "element") { @@ -347,11 +378,12 @@ app.controller('AfExplorerFormCtrl', [ if (node.title !== $scope.config.element_name) { $scope.config.element_name = ""; } - $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; node.children.forEach(child => { if (child.type === "attribute") { - $scope.config.attributeList.push(child); + const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); + if (!isAlreadyPresent) { + $scope.config.attributeList.push(child); + } } }); } @@ -401,6 +433,7 @@ app.component('treeNode', { const index = ctrl.config.clickedNodes.indexOf(node.url); if (index > -1) { ctrl.config.clickedNodes.splice(index, 1); + ctrl.displayAttributes(node,false); } else { ctrl.config.clickedNodes.push(node.url); ctrl.displayAttributes(node); From ce92aabd4bff72d21fb40fccc32900458dc7a63f Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Mon, 23 Mar 2026 22:00:36 +0100 Subject: [PATCH 087/156] *load element's children from db when not loaded *keep search result after node click --- js/pi-system_treecontroller.js | 88 ++++++++++++++++++++++++------ resource/pi-system_af-explorer.css | 18 +++++- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 7ce8a3f5..ab9c31c4 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -40,6 +40,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attributeList = $scope.config.attributeList || []; $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; $scope.config.clickedNodes = $scope.config.clickedNodes || []; + $scope.config.searchMatchedElementPaths = $scope.config.searchMatchedElementPaths || []; $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); @@ -125,6 +126,8 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.treeData = []; $scope.config.clickedNodes = []; $scope.config.attributeList = []; + $scope.config.selectedAttributes = []; + $scope.config.searchMatchedElementPaths = []; } $scope.resetDatasourceState = function () { @@ -205,6 +208,7 @@ app.controller('AfExplorerFormCtrl', [ item.children.forEach(child => { child.expanded = false; }); + markSearchResults(item.children, $scope.config.searchMatchedElementPaths || []); console.log(item); return item; }); @@ -265,15 +269,50 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attributeList = []; $scope.config.selectedAttributes = []; $scope.config.clickedNodes = []; + $scope.config.searchMatchedElementPaths = []; $scope.callPythonDo({ method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData }).then( function (data) { TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); - $scope.config.attributeList = data.attributes; + const matchedElementPaths = getMatchedElementPaths(data.attributes || []); + $scope.config.searchMatchedElementPaths = matchedElementPaths; + markSearchResults($scope.config.treeData, matchedElementPaths); } ); }; + function getMatchedElementPaths(attributes) { + const matchedPathSet = new Set(); + attributes.forEach(attribute => { + const fullPath = attribute && attribute.path; + if (!fullPath || typeof fullPath !== "string") { + return; + } + const elementPath = fullPath.includes("|") ? fullPath.split("|")[0] : fullPath; + matchedPathSet.add(elementPath); + }); + return Array.from(matchedPathSet); + } + + function markSearchResults(nodes, matchedElementPaths) { + if (!Array.isArray(nodes)) { + return; + } + const matchedPathSet = new Set(matchedElementPaths || []); + + nodes.forEach(node => { + node.searchHighlighted = + node && + node.type !== "attribute" && + !!node.path && + matchedPathSet.has(node.path); + + if (Array.isArray(node.children) && node.children.length > 0) { + markSearchResults(node.children, matchedElementPaths); + } + }); + } + $scope.onSearchInputKeydown = function ($event) { if ($event && ($event.key === "Enter" || $event.keyCode === 13)) { $event.preventDefault(); @@ -350,6 +389,11 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectedAttributes.length === $scope.config.attributeList.length; } + function hasAttributeChildren(node) { + return Array.isArray(node && node.children) && + node.children.some(child => child.type === "attribute"); + } + $scope.displayAttributes = function (node, shouldAdd = true) { $scope.config.selectAllAttributes = false; if (!shouldAdd) { @@ -357,27 +401,29 @@ app.controller('AfExplorerFormCtrl', [ return; } - if (!node.children || node.children.length === 0) { - - if (node.type === "element") { - $scope.config.template = "-- Any --"; - $scope.getChildrenFromDB(node).then(newNode => { - processNode(newNode); - }); - } else if (node.type === "template") { - $scope.config.element_name = "*"; - $scope.config.template = node.title; - $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); - } - } else { + const shouldLoadChildrenFromDb = + node.type === "element" && + ( + !Array.isArray(node.children) || + node.children.length === 0 || + !hasAttributeChildren(node) + ); + + if (shouldLoadChildrenFromDb) { + $scope.config.template = "-- Any --"; + $scope.getChildrenFromDB(node).then(newNode => { + processNode(newNode); + }); + } else if (node.type === "template") { + $scope.config.element_name = "*"; + $scope.config.template = node.title; + $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); + } else { processNode(node); - }; + } } function processNode(node) { - if (node.title !== $scope.config.element_name) { - $scope.config.element_name = ""; - } node.children.forEach(child => { if (child.type === "attribute") { const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); @@ -459,6 +505,10 @@ app.component('treeNode', { ctrl.isNodeClicked = function (node) { return ctrl.config.clickedNodes.includes(node.url); }; + + ctrl.isSearchResult = function (node) { + return !!node.searchHighlighted; + }; }, template: ` @@ -478,6 +528,8 @@ app.component('treeNode', { class="tree-node__label" ng-click="ctrl.onNodeClick(ctrl.node)" ng-class="{ + 'tree-node__label--search-result': + ctrl.isSearchResult(ctrl.node), 'tree-node__label--clickable': ctrl.isNodeClicked(ctrl.node) }" diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index af833728..129a870f 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -2,9 +2,25 @@ width: 100% !important; } -.tree-node__label--clickable{ +.tree-node__label--search-result{ background-color: yellow; } + +.tree-node__label--clickable { + box-shadow: inset 0 0 0 1px #2e7d32; + border-radius: 2px; +} + +.tree-node__label--clickable::after { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background-color: #2e7d32; + vertical-align: middle; +} .generic-class { display: flex; align-items: flex-start; From 9a150fcb4d02053c3cc4c6bc07c43b15e70bc0eb Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 24 Mar 2026 15:59:19 +0100 Subject: [PATCH 088/156] *fix attribute scope with clicked nodes *auto-display attribute-only search results *persist output-selected attributes --- resource/browse_af_tree.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index d80de64d..b2c4f075 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -81,8 +81,17 @@ def do(payload, config, plugin_config, inputs): if attribute_category == "-- Any --": attribute_category = None database_name = config.get("database_name") - element_name = config.get("element_name") - attribute_name = config.get("attribute_name") + element_name = config.get("element_name").strip() + attribute_name = config.get("attribute_name").strip() + if element_name == "": + element_name = None + if attribute_name == "": + attribute_name = None + + has_attribute_filter = attribute_name is not None + has_element_filter = element_name is not None + if not (has_attribute_filter and not has_element_filter): + clicked_nodes = [] # root_tree = payload.get("root_tree") root_tree = config.get("treeData", []) root_tree = shorten_tree(root_tree) From 2fd709d50a9bcb49330b09922c4f0e0e344b3683 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 24 Mar 2026 15:59:46 +0100 Subject: [PATCH 089/156] preserve selection for the output --- custom-recipes/pi-system-af-tree/recipe.py | 4 +- js/pi-system_treecontroller.js | 143 +++++++++++++++++++-- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 3212c572..6ada93bd 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -61,9 +61,9 @@ def next_tree_item(tree_data): ] output_dataset.write_schema(schema) -selectedAttributes = config.get("selectedAttributes", []) +selectedAttributes = config.get("outputSelectedAttributes", config.get("selectedAttributes", [])) with output_dataset.get_writer() as writer: - for item in selectedAttributes : + for item in selectedAttributes: if item.get("checked", True) is True: writer.write_row_dict(item) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index ab9c31c4..57ed2880 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -39,8 +39,11 @@ app.controller('AfExplorerFormCtrl', [ $scope.templateTreeData = TreeDataService.getTemplateTreeData(); $scope.config.attributeList = $scope.config.attributeList || []; $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; + $scope.config.outputSelectedAttributes = $scope.config.outputSelectedAttributes || []; $scope.config.clickedNodes = $scope.config.clickedNodes || []; $scope.config.searchMatchedElementPaths = $scope.config.searchMatchedElementPaths || []; + $scope.config.searchMatchedAttributePaths = $scope.config.searchMatchedAttributePaths || []; + $scope.config.currentSearchRestrictsAttributes = $scope.config.currentSearchRestrictsAttributes || false; $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); @@ -127,7 +130,10 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.clickedNodes = []; $scope.config.attributeList = []; $scope.config.selectedAttributes = []; + $scope.config.outputSelectedAttributes = []; $scope.config.searchMatchedElementPaths = []; + $scope.config.searchMatchedAttributePaths = []; + $scope.config.currentSearchRestrictsAttributes = false; } $scope.resetDatasourceState = function () { @@ -141,6 +147,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.element_categories = []; $scope.config.attributeList = []; $scope.config.selectedAttributes = []; + $scope.config.outputSelectedAttributes = []; $scope.showTreeData = false; $scope.showTemplateTreeData = false; $scope.cleanTree(); @@ -266,21 +273,54 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.doSearch = function (element_name, attribute_name) { - $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; - $scope.config.clickedNodes = []; + const hasElementFilter = !!(element_name && element_name.trim()); + const hasClickedNodes = Array.isArray($scope.config.clickedNodes) && $scope.config.clickedNodes.length > 0; + const hasAttributeFilter = !!(attribute_name && attribute_name.trim()); + const isRestrictedAttributeSearch = hasClickedNodes && hasAttributeFilter && !hasElementFilter; + const shouldDisplaySearchAttributesDirectly = hasAttributeFilter && !hasElementFilter; + $scope.config.currentSearchRestrictsAttributes = isRestrictedAttributeSearch; + + if (!isRestrictedAttributeSearch) { + $scope.config.attributeList = []; + $scope.config.selectedAttributes = []; + } $scope.config.searchMatchedElementPaths = []; + $scope.config.searchMatchedAttributePaths = []; $scope.callPythonDo({ method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData }).then( function (data) { TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); - const matchedElementPaths = getMatchedElementPaths(data.attributes || []); + const matchedAttributes = data.attributes || []; + const matchedElementPaths = getMatchedElementPaths(matchedAttributes); + $scope.config.searchMatchedAttributePaths = getMatchedAttributePaths(matchedAttributes); $scope.config.searchMatchedElementPaths = matchedElementPaths; markSearchResults($scope.config.treeData, matchedElementPaths); + if (isRestrictedAttributeSearch || shouldDisplaySearchAttributesDirectly) { + applySearchAttributesToList(matchedAttributes); + } } ); }; + function applySearchAttributesToList(attributes) { + const preservedSelectedPathSet = new Set(getOutputSelectedAttributes().map(attr => attr.path)); + const seen = new Set(); + const deduped = []; + + attributes.forEach(attribute => { + if (!attribute || !attribute.path || seen.has(attribute.path)) { + return; + } + seen.add(attribute.path); + const attrCopy = { ...attribute }; + attrCopy.checked = preservedSelectedPathSet.has(attrCopy.path); + deduped.push(attrCopy); + }); + + $scope.config.attributeList = deduped; + syncVisibleSelectionFromOutput(); + } + function getMatchedElementPaths(attributes) { const matchedPathSet = new Set(); attributes.forEach(attribute => { @@ -294,6 +334,16 @@ app.controller('AfExplorerFormCtrl', [ return Array.from(matchedPathSet); } + function getMatchedAttributePaths(attributes) { + const matchedPathSet = new Set(); + attributes.forEach(attribute => { + if (attribute && attribute.path) { + matchedPathSet.add(attribute.path); + } + }); + return Array.from(matchedPathSet); + } + function markSearchResults(nodes, matchedElementPaths) { if (!Array.isArray(nodes)) { return; @@ -323,10 +373,16 @@ app.controller('AfExplorerFormCtrl', [ $scope.toggleSelectAllAttributes = function () { if ($scope.config.selectAllAttributes) { $scope.config.selectedAttributes = [...$scope.config.attributeList]; - $scope.config.attributeList.forEach(attr => attr.checked = true); + $scope.config.attributeList.forEach(attr => { + attr.checked = true; + upsertOutputSelectedAttribute(attr, true); + }); } else { $scope.config.selectedAttributes = []; - $scope.config.attributeList.forEach(attr => attr.checked = false); + $scope.config.attributeList.forEach(attr => { + attr.checked = false; + upsertOutputSelectedAttribute(attr, false); + }); } } @@ -343,6 +399,7 @@ app.controller('AfExplorerFormCtrl', [ const attrInConfig = attributeList.find(attr => attr.path === attribute.path); if (attrInConfig) attrInConfig.checked = false; + upsertOutputSelectedAttribute(attribute, false); $scope.config.selectAllAttributes = false; return; @@ -357,6 +414,7 @@ app.controller('AfExplorerFormCtrl', [ selectedAttributes.push(attribute); attrInConfig.checked = true; + upsertOutputSelectedAttribute(attribute, true); $scope.config.selectAllAttributes = selectedAttributes.length === attributeList.length; }; @@ -384,9 +442,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectedAttributes = ($scope.config.selectedAttributes || []).filter( attr => !attributePaths.includes(attr.path) ); - $scope.config.selectAllAttributes = - $scope.config.attributeList.length > 0 && - $scope.config.selectedAttributes.length === $scope.config.attributeList.length; + syncVisibleSelectionFromOutput(); } function hasAttributeChildren(node) { @@ -424,14 +480,83 @@ app.controller('AfExplorerFormCtrl', [ } function processNode(node) { + const selectedPaths = new Set(getOutputSelectedAttributes().map(attr => attr.path)); + const hasAttributeFilter = !!($scope.config.attribute_name && $scope.config.attribute_name.trim()); + const shouldRestrictDisplayedAttributes = hasAttributeFilter; + node.children.forEach(child => { if (child.type === "attribute") { + if (shouldRestrictDisplayedAttributes && !attributeMatchesCurrentSearch(child)) { + return; + } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { + child.checked = selectedPaths.has(child.path); $scope.config.attributeList.push(child); } } }); + + syncVisibleSelectionFromOutput(); + } + + function attributeMatchesCurrentSearch(attribute) { + const rawFilter = ($scope.config.attribute_name || "").trim(); + if (!rawFilter) { + return true; + } + + const attributeTitle = (attribute && attribute.title ? attribute.title : "").toLowerCase(); + const filter = rawFilter.toLowerCase(); + + if (filter.includes("*")) { + const regexPattern = "^" + escapeRegex(filter).replace(/\\\*/g, ".*") + "$"; + return new RegExp(regexPattern).test(attributeTitle); + } + + return attributeTitle.includes(filter); + } + + function escapeRegex(input) { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function getOutputSelectedAttributes() { + if (!Array.isArray($scope.config.outputSelectedAttributes)) { + $scope.config.outputSelectedAttributes = []; + } + return $scope.config.outputSelectedAttributes; + } + + function upsertOutputSelectedAttribute(attribute, isChecked) { + if (!attribute || !attribute.path) { + return; + } + + const outputSelectedAttributes = getOutputSelectedAttributes(); + const index = outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); + + if (isChecked) { + const attributeToStore = { ...attribute, checked: true }; + if (index === -1) { + outputSelectedAttributes.push(attributeToStore); + } else { + outputSelectedAttributes[index] = attributeToStore; + } + } else if (index !== -1) { + outputSelectedAttributes.splice(index, 1); + } + } + + function syncVisibleSelectionFromOutput() { + const outputSelectedPathSet = new Set(getOutputSelectedAttributes().map(attr => attr.path)); + $scope.config.attributeList.forEach(attr => { + attr.checked = outputSelectedPathSet.has(attr.path); + }); + $scope.config.selectedAttributes = $scope.config.attributeList.filter(attr => attr.checked); + $scope.config.selectAllAttributes = + $scope.config.attributeList.length > 0 && + $scope.config.selectedAttributes.length === $scope.config.attributeList.length; } From 3927a006bf202a9704b5a49fed69029f241aabe5 Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Wed, 25 Mar 2026 11:43:25 +0100 Subject: [PATCH 090/156] *add safety check on element and attribute name *stabilize attribute /element search scope --- js/pi-system_treecontroller.js | 12 +++++++++++- resource/browse_af_tree.py | 16 ++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 57ed2880..fd805475 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -44,6 +44,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.searchMatchedElementPaths = $scope.config.searchMatchedElementPaths || []; $scope.config.searchMatchedAttributePaths = $scope.config.searchMatchedAttributePaths || []; $scope.config.currentSearchRestrictsAttributes = $scope.config.currentSearchRestrictsAttributes || false; + $scope.config.lastSearchedElementName = $scope.config.lastSearchedElementName || ""; $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); @@ -134,6 +135,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.searchMatchedElementPaths = []; $scope.config.searchMatchedAttributePaths = []; $scope.config.currentSearchRestrictsAttributes = false; + $scope.config.lastSearchedElementName = ""; } $scope.resetDatasourceState = function () { @@ -274,11 +276,19 @@ app.controller('AfExplorerFormCtrl', [ $scope.doSearch = function (element_name, attribute_name) { const hasElementFilter = !!(element_name && element_name.trim()); + const hadPreviousElementFilter = !!($scope.config.lastSearchedElementName && $scope.config.lastSearchedElementName.trim()); + + // If user clears element filter after a scoped search, release previous click-based scope. + if (!hasElementFilter && hadPreviousElementFilter) { + $scope.config.clickedNodes = []; + } + const hasClickedNodes = Array.isArray($scope.config.clickedNodes) && $scope.config.clickedNodes.length > 0; const hasAttributeFilter = !!(attribute_name && attribute_name.trim()); const isRestrictedAttributeSearch = hasClickedNodes && hasAttributeFilter && !hasElementFilter; - const shouldDisplaySearchAttributesDirectly = hasAttributeFilter && !hasElementFilter; + const shouldDisplaySearchAttributesDirectly = hasAttributeFilter; $scope.config.currentSearchRestrictsAttributes = isRestrictedAttributeSearch; + $scope.config.lastSearchedElementName = element_name || ""; if (!isRestrictedAttributeSearch) { $scope.config.attributeList = []; diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index b2c4f075..6ea64850 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -81,12 +81,16 @@ def do(payload, config, plugin_config, inputs): if attribute_category == "-- Any --": attribute_category = None database_name = config.get("database_name") - element_name = config.get("element_name").strip() - attribute_name = config.get("attribute_name").strip() - if element_name == "": - element_name = None - if attribute_name == "": - attribute_name = None + element_name = config.get("element_name") + attribute_name = config.get("attribute_name") + if isinstance(element_name, str): + element_name = element_name.strip() + if element_name == "": + element_name = None + if isinstance(attribute_name, str): + attribute_name = attribute_name.strip() + if attribute_name == "": + attribute_name = None has_attribute_filter = attribute_name is not None has_element_filter = element_name is not None From 52b5c82bb58f30cdcd9ad5acc170941108858ded Mon Sep 17 00:00:00 2001 From: JaneBellaiche Date: Tue, 7 Apr 2026 17:43:00 +0200 Subject: [PATCH 091/156] Mulltiselect for element and tempalte trees --- js/pi-system_treecontroller.js | 258 +++++++++++++++++++++++++++- resource/browse_af_tree.py | 36 +++- resource/pi-system_af-explorer.html | 11 +- 3 files changed, 288 insertions(+), 17 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index fd805475..e488e85f 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -45,6 +45,8 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.searchMatchedAttributePaths = $scope.config.searchMatchedAttributePaths || []; $scope.config.currentSearchRestrictsAttributes = $scope.config.currentSearchRestrictsAttributes || false; $scope.config.lastSearchedElementName = $scope.config.lastSearchedElementName || ""; + $scope.config.pendingTabContextReset = $scope.config.pendingTabContextReset || false; + $scope.config.selectedTemplateNames = $scope.config.selectedTemplateNames || []; $scope.editorOptions = CodeMirrorSettingService.get("text/plain"); @@ -116,9 +118,10 @@ app.controller('AfExplorerFormCtrl', [ } $scope.explore = function () { if ($scope.authConfigured()) { + $scope.updateDatas(); $scope.showTreeData = true; $scope.showTemplateTreeData = true; - $scope.toggleAuthSection(); + $scope.authSectionVisible = false; } }; @@ -136,6 +139,8 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.searchMatchedAttributePaths = []; $scope.config.currentSearchRestrictsAttributes = false; $scope.config.lastSearchedElementName = ""; + $scope.config.pendingTabContextReset = false; + $scope.config.selectedTemplateNames = []; } $scope.resetDatasourceState = function () { @@ -147,6 +152,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.templateTreeData = []; $scope.config.attribute_categories = []; $scope.config.element_categories = []; + $scope.config.loadedDatabaseName = null; $scope.config.attributeList = []; $scope.config.selectedAttributes = []; $scope.config.outputSelectedAttributes = []; @@ -155,6 +161,30 @@ app.controller('AfExplorerFormCtrl', [ $scope.cleanTree(); }; + $scope.onServerChanged = function () { + $scope.config.database_name = null; + $scope.config.templates = []; + $scope.config.templateTreeData = []; + $scope.config.attribute_categories = []; + $scope.config.element_categories = []; + $scope.config.loadedDatabaseName = null; + $scope.showTreeData = false; + $scope.showTemplateTreeData = false; + $scope.cleanTree(); + $scope.getDatabases(); + }; + + $scope.onDatabaseChanged = function () { + $scope.config.templates = []; + $scope.config.templateTreeData = []; + $scope.config.attribute_categories = []; + $scope.config.element_categories = []; + $scope.config.loadedDatabaseName = null; + $scope.showTreeData = false; + $scope.showTemplateTreeData = false; + $scope.cleanTree(); + }; + var presetWatchInitialized = false; $scope.$watchGroup( [ @@ -205,7 +235,9 @@ app.controller('AfExplorerFormCtrl', [ $scope.initializeTree(); $scope.getTemplatesFromDB(); $scope.getCategoriesFromDB(); - $scope.showTreeData = false; + $scope.config.loadedDatabaseName = $scope.config.database_name || null; + $scope.showTreeData = true; + $scope.showTemplateTreeData = true; } $scope.getChildrenFromDB = function (item) { @@ -245,7 +277,36 @@ app.controller('AfExplorerFormCtrl', [ }); } + function resetRightPanelForCurrentTabContext() { + $scope.config.attribute_name = ""; + $scope.config.clickedNodes = []; + $scope.config.attributeList = []; + $scope.config.selectedAttributes = []; + $scope.config.selectAllAttributes = false; + $scope.config.searchMatchedElementPaths = []; + $scope.config.searchMatchedAttributePaths = []; + $scope.config.currentSearchRestrictsAttributes = false; + $scope.config.selectedTemplateNames = []; + if ($scope.config.activeTab === "element") { + $scope.config.template = "-- Any --"; + } else if ($scope.config.activeTab === "template") { + $scope.config.element_name = ""; + } + } + + function consumePendingTabContextReset() { + if (!$scope.config.pendingTabContextReset) { + return; + } + resetRightPanelForCurrentTabContext(); + $scope.config.pendingTabContextReset = false; + } + $scope.setTab = function(tab) { + const previousTab = $scope.config.activeTab; + if (tab !== previousTab) { + $scope.config.pendingTabContextReset = true; + } $scope.config.activeTab = tab; }; @@ -275,6 +336,8 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.doSearch = function (element_name, attribute_name) { + consumePendingTabContextReset(); + const hasElementFilter = !!(element_name && element_name.trim()); const hadPreviousElementFilter = !!($scope.config.lastSearchedElementName && $scope.config.lastSearchedElementName.trim()); @@ -286,9 +349,28 @@ app.controller('AfExplorerFormCtrl', [ const hasClickedNodes = Array.isArray($scope.config.clickedNodes) && $scope.config.clickedNodes.length > 0; const hasAttributeFilter = !!(attribute_name && attribute_name.trim()); const isRestrictedAttributeSearch = hasClickedNodes && hasAttributeFilter && !hasElementFilter; - const shouldDisplaySearchAttributesDirectly = hasAttributeFilter; + const hasTemplateFilter = !!( + $scope.config.template && + $scope.config.template !== "-- Any --" + ); + const isTemplateScopedSearch = + hasTemplateFilter && + ($scope.config.activeTab === "template"); + const shouldDisplaySearchAttributesDirectly = + hasAttributeFilter || isTemplateScopedSearch; $scope.config.currentSearchRestrictsAttributes = isRestrictedAttributeSearch; $scope.config.lastSearchedElementName = element_name || ""; + if ($scope.config.activeTab === "template") { + $scope.config.selectedTemplateNames = getSelectedTemplateNamesFromClickedNodes(); + } else { + $scope.config.selectedTemplateNames = []; + } + const hasSelectedTemplateNodes = ( + $scope.config.activeTab === "template" && + Array.isArray($scope.config.selectedTemplateNames) && + $scope.config.selectedTemplateNames.length > 0 + ); + const shouldShowTemplateSelectionAttributes = hasSelectedTemplateNodes; if (!isRestrictedAttributeSearch) { $scope.config.attributeList = []; @@ -305,7 +387,11 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.searchMatchedAttributePaths = getMatchedAttributePaths(matchedAttributes); $scope.config.searchMatchedElementPaths = matchedElementPaths; markSearchResults($scope.config.treeData, matchedElementPaths); - if (isRestrictedAttributeSearch || shouldDisplaySearchAttributesDirectly) { + if ( + isRestrictedAttributeSearch || + shouldDisplaySearchAttributesDirectly || + shouldShowTemplateSelectionAttributes + ) { applySearchAttributesToList(matchedAttributes); } } @@ -354,6 +440,46 @@ app.controller('AfExplorerFormCtrl', [ return Array.from(matchedPathSet); } + function collectTemplateTitlesByClickedUrls(nodes, clickedUrlSet, outputSet) { + if (!Array.isArray(nodes) || !clickedUrlSet || !outputSet) { + return; + } + + nodes.forEach(function (node) { + if (!node) { + return; + } + if ( + clickedUrlSet.has(node.url) && + node.type === "template" && + node.title && + node.title !== "-- Any --" + ) { + outputSet.add(node.title); + } + if (Array.isArray(node.children) && node.children.length > 0) { + collectTemplateTitlesByClickedUrls(node.children, clickedUrlSet, outputSet); + } + }); + } + + function getSelectedTemplateNamesFromClickedNodes() { + const clickedUrls = Array.isArray($scope.config.clickedNodes) + ? $scope.config.clickedNodes + : []; + if (!clickedUrls.length) { + return []; + } + + const selectedTemplateNames = new Set(); + collectTemplateTitlesByClickedUrls( + $scope.config.templateTreeData, + new Set(clickedUrls), + selectedTemplateNames + ); + return Array.from(selectedTemplateNames); + } + function markSearchResults(nodes, matchedElementPaths) { if (!Array.isArray(nodes)) { return; @@ -380,6 +506,15 @@ app.controller('AfExplorerFormCtrl', [ } }; + $scope.searchFromElement = function () { + if (!$scope.config) { + return; + } + + // Left search always resets right-side filter/template search. + $scope.doSearch($scope.config.element_name, ""); + }; + $scope.toggleSelectAllAttributes = function () { if ($scope.config.selectAllAttributes) { $scope.config.selectedAttributes = [...$scope.config.attributeList]; @@ -481,8 +616,22 @@ app.controller('AfExplorerFormCtrl', [ processNode(newNode); }); } else if (node.type === "template") { + const selectedTemplateNames = getSelectedTemplateNamesFromClickedNodes(); + if (!selectedTemplateNames.length) { + $scope.config.template = "-- Any --"; + $scope.config.attributeList = []; + $scope.config.selectedAttributes = []; + $scope.config.searchMatchedElementPaths = []; + $scope.config.searchMatchedAttributePaths = []; + return; + } + + // Keep previous single-template behavior in config when only one is selected. + // For multi-select, backend will use selectedTemplateNames. + $scope.config.template = selectedTemplateNames.length === 1 + ? selectedTemplateNames[0] + : "-- Any --"; $scope.config.element_name = "*"; - $scope.config.template = node.title; $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); } else { processNode(node); @@ -588,6 +737,74 @@ app.component('treeNode', { controller: function () { const ctrl = this; + function consumePendingTabContextReset() { + if (!ctrl.config || !ctrl.config.pendingTabContextReset) { + return; + } + + ctrl.config.attribute_name = ""; + ctrl.config.clickedNodes = []; + ctrl.config.attributeList = []; + ctrl.config.selectedAttributes = []; + ctrl.config.selectAllAttributes = false; + ctrl.config.searchMatchedElementPaths = []; + ctrl.config.searchMatchedAttributePaths = []; + ctrl.config.currentSearchRestrictsAttributes = false; + + if (ctrl.config.activeTab === "element") { + ctrl.config.template = "-- Any --"; + } else if (ctrl.config.activeTab === "template") { + ctrl.config.element_name = ""; + } + + ctrl.config.pendingTabContextReset = false; + } + + function findNodeByUrl(nodes, targetUrl) { + if (!Array.isArray(nodes) || !targetUrl) { + return null; + } + + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node) { + continue; + } + if (node.url === targetUrl) { + return node; + } + const childMatch = findNodeByUrl(node.children, targetUrl); + if (childMatch) { + return childMatch; + } + } + + return null; + } + + function rebuildAttributesFromClickedNodes() { + const clickedUrls = Array.isArray(ctrl.config && ctrl.config.clickedNodes) + ? ctrl.config.clickedNodes + : []; + + ctrl.config.attributeList = []; + ctrl.config.selectedAttributes = []; + ctrl.config.selectAllAttributes = false; + + if (!clickedUrls.length) { + return; + } + + clickedUrls.forEach(function (url) { + const node = + findNodeByUrl(ctrl.config.treeData, url) || + findNodeByUrl(ctrl.config.templateTreeData, url); + if (node) { + ctrl.displayAttributes(node, true); + } + }); + } + ctrl.hasRenderableChildren = function (node) { if (!node || !Array.isArray(node.children) || !node.children.length) { return false; @@ -611,12 +828,41 @@ app.component('treeNode', { }; ctrl.onNodeClick = function (node) { + consumePendingTabContextReset(); + + const hadRightSearch = !!( + ctrl.config && + ctrl.config.attribute_name && + ctrl.config.attribute_name.trim() + ); + + if (ctrl.config) { + // Clicking a left node resets right-side attribute/template search. + ctrl.config.attribute_name = ""; + if (node && node.type === "element") { + ctrl.config.template = "-- Any --"; + } + } + const index = ctrl.config.clickedNodes.indexOf(node.url); if (index > -1) { ctrl.config.clickedNodes.splice(index, 1); - ctrl.displayAttributes(node,false); } else { ctrl.config.clickedNodes.push(node.url); + } + + if (node && node.type === "template") { + // Template clicks should always rebuild right-side content from the full template selection. + ctrl.displayAttributes(node, true); + console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); + return; + } + + if (hadRightSearch) { + rebuildAttributesFromClickedNodes(); + } else if (index > -1) { + ctrl.displayAttributes(node, false); + } else { ctrl.displayAttributes(node); } diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index 6ea64850..0df8a2ad 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -70,8 +70,16 @@ def do(payload, config, plugin_config, inputs): template_name = config.get("template", None) category_name = config.get("element_category", None) clicked_nodes = config.get("clickedNodes", []) + active_tab = config.get("activeTab") + selected_template_names = config.get("selectedTemplateNames", []) if template_name == "-- Any --": template_name = None + if not isinstance(selected_template_names, list): + selected_template_names = [] + selected_template_names = [ + template_name_item for template_name_item in selected_template_names + if isinstance(template_name_item, str) and template_name_item and template_name_item != "-- Any --" + ] if category_name == "-- Any --": category_name = None element_category = config.get("element_category", None) @@ -94,7 +102,12 @@ def do(payload, config, plugin_config, inputs): has_attribute_filter = attribute_name is not None has_element_filter = element_name is not None - if not (has_attribute_filter and not has_element_filter): + is_template_tab = active_tab == "template" + is_template_search_with_selected_nodes = ( + is_template_tab and len(selected_template_names) > 0 + ) + + if not (has_attribute_filter and not has_element_filter) and not is_template_search_with_selected_nodes: clicked_nodes = [] # root_tree = payload.get("root_tree") root_tree = config.get("treeData", []) @@ -105,11 +118,22 @@ def do(payload, config, plugin_config, inputs): elements_max_count, attributes_max_count = get_max_counts(config) attributes = [] - for result in client.batched_search(database_name, element_name, attribute_name, - element_category, attribute_category, template_name, clicked_nodes, - elements_max_count=elements_max_count, attributes_max_count=attributes_max_count): - # result["checked"] = True - attributes.append(result) + if is_template_search_with_selected_nodes: + # In template tab with selected template nodes, scope searches to all selected templates. + # We ignore element_name here to avoid stale "*" from single-template click behavior. + for selected_template_name in selected_template_names: + for result in client.batched_search( + database_name, None, attribute_name, + element_category, attribute_category, selected_template_name, [], + elements_max_count=elements_max_count, attributes_max_count=attributes_max_count + ): + attributes.append(result) + else: + for result in client.batched_search(database_name, element_name, attribute_name, + element_category, attribute_category, template_name, clicked_nodes, + elements_max_count=elements_max_count, attributes_max_count=attributes_max_count): + # result["checked"] = True + attributes.append(result) attributes = split_real_from_linked_paths(attributes) items = [] for attribute in attributes: diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index f95dee86..9d7f2158 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -23,7 +23,8 @@
              @@ -31,7 +32,7 @@
              @@ -66,7 +67,7 @@
              @@ -116,7 +117,7 @@
              -
              +
              Element
              Template
              @@ -126,7 +127,7 @@ data-container="body" data-html="true" placeholder="Search element" ng-keydown="onSearchInputKeydown($event)" />
              - + Advanced parameters
              -
              Disable SSL Check
              +
              Disable SSL Check +
              Elements max count
              -
              +
              Attributes max count
              -
              +
              -
              +
              + +
              -
              - -
              - +
              + +
              + +
              -
              - -
              -
              -
              Attributes
              + Attributes - free floating, unattached to templates +
              Elements
              - + @@ -130,7 +131,7 @@ - + From ad3b9521d89cdaf20f2a4ea97ee83404d0f1b0db Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 22 Apr 2026 12:07:02 +0200 Subject: [PATCH 108/156] refacto: simplify logic --- js/pi-system_treecontroller.js | 53 +++++++++++++++++----------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 11644bf8..2d3c0abe 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -615,26 +615,24 @@ app.controller('AfExplorerFormCtrl', [ syncVisibleSelectionFromOutput(); } + // TODO: double check the logic function hasAttributeChildren(node) { - return Array.isArray(node?.children) && - node.children.some(child => child.type === "attribute"); + if (!Array.isArray(node.children) || node.children.length === 0) { + return false + } + return node.children.some(child => child.type === "attribute"); } - $scope.displayAttributes = function(node, shouldAdd = true) { + // TODO: cleanup + $scope.displayAttributes = function(node, remove = true) { $scope.config.selectAllWithoutTemplateAttributes = false; $scope.config.selectAllTemplateAttributes = false; - if (!shouldAdd) { + if (!remove) { removeNodeAttributes(node); return; } - const shouldLoadChildrenFromDb = - node.type === "element" && - ( - !Array.isArray(node.children) || - node.children.length === 0 || - !hasAttributeChildren(node) - ); + const shouldLoadChildrenFromDb = node.type === "element" && !hasAttributeChildren(node); if (shouldLoadChildrenFromDb) { $scope.config.template = "-- Any --"; @@ -668,7 +666,6 @@ app.controller('AfExplorerFormCtrl', [ function processNode(node) { const selectedPaths = new Set(getOutputSelectedAttributes().map(attr => attr.path)); const hasAttributeFilter = !!($scope.config.attribute_name?.trim()); - const shouldRestrictDisplayedAttributes = hasAttributeFilter; const parentTemplateName = node?.template_name ? node.template_name : null; node.children.forEach(child => { @@ -676,7 +673,7 @@ app.controller('AfExplorerFormCtrl', [ if (!child.parent_template_name && parentTemplateName) { child.parent_template_name = parentTemplateName; } - if (shouldRestrictDisplayedAttributes && !attributeMatchesCurrentSearch(child)) { + if (hasAttributeFilter && !attributeMatchesCurrentSearch(child)) { return; } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); @@ -891,6 +888,7 @@ app.component('treeNode', { return null; } + // TODO: understand why the logic is different from displayAttributes (merge them if possible) function rebuildAttributesFromClickedNodes() { const clickedUrls = Array.isArray(ctrl.config?.clickedNodes) ? ctrl.config.clickedNodes @@ -941,28 +939,31 @@ app.component('treeNode', { ctrl.onNodeClick = function(node) { consumePendingTabContextReset(); + // TODO: factorize this check const hasActiveAttributeSearch = !!( ctrl.config?.attribute_name?.trim() ); - if (ctrl.config) { - // Keep right-side attribute search when active so multi-node clicks can - // enrich results with the same filter (ex: "Load" on California + Fresno). - if (!hasActiveAttributeSearch) { - ctrl.config.attribute_name = ""; - } - if (node?.type === "element") { - ctrl.config.template = "-- Any --"; - } + // Keep right-side attribute search when active so multi-node clicks can + // enrich results with the same filter (ex: "Load" on California + Fresno). + // TODO: understand why we need a reset if the attribute search is empty + if (!hasActiveAttributeSearch) { + ctrl.config.attribute_name = ""; + } + if (node?.type === "element") { + // TODO: factorize this reset + ctrl.config.template = "-- Any --"; } - const index = ctrl.config.clickedNodes.indexOf(node.url); - if (index > -1) { - ctrl.config.clickedNodes.splice(index, 1); + const indexClickedNode = ctrl.config.clickedNodes.indexOf(node.url); + // If the node is already clicked, remove it from clicked nodes - else add it + if (indexClickedNode > -1) { + ctrl.config.clickedNodes.splice(indexClickedNode, 1); } else { ctrl.config.clickedNodes.push(node.url); } + // TODO: split element/template logic if (node?.type === "template") { // Template clicks should always rebuild right-side content from the full template selection. ctrl.displayAttributes(node, true); @@ -972,7 +973,7 @@ app.component('treeNode', { if (hasActiveAttributeSearch) { rebuildAttributesFromClickedNodes(); - } else if (index > -1) { + } else if (indexClickedNode > -1) { ctrl.displayAttributes(node, false); } else { ctrl.displayAttributes(node); From c63ae230e904c79822e73b0be47c390b3c5b6cbc Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 22 Apr 2026 12:07:30 +0200 Subject: [PATCH 109/156] feat: attribute deduplication function --- js/pi-system_treecontroller.js | 39 +++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 2d3c0abe..7ad426f0 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -759,10 +759,47 @@ app.controller('AfExplorerFormCtrl', [ return getGroupedAttributesByTemplate().attributesWithoutTemplate; }; + // TODO: investigate why it is called very frequently $scope.getTemplateGroups = function() { - return getGroupedAttributesByTemplate().templateGroups; + const groupedAttributes = getGroupedAttributesByTemplate(); + const templateGroups = groupedAttributes.templateGroups; + const templateGroupsDeduplicated = templateGroups.reduce((acc, group) => { + group.attributes.forEach((attr) => { + const key = attr.title; + + if (!acc[key]) { + acc[key] = { + title: attr.title, + description: attr.description, + attributes: [], + checked: [], + }; + } + + acc[key].attributes.push({ + category_names: attr.category_names, + has_children: attr.has_children, + path: attr.path, + id: attr.id, + url: attr.url, + type: attr.type, + children: attr.children, + expanded: attr.expanded, + searchHighlighted: attr.searchHighlighted, + parent_template_name: attr.parent_template_name, + checked: attr.checked + }); + + acc[key].checked.push(attr.checked) + + }); + + return acc; + }, {}); + return templateGroupsDeduplicated; }; + function attributeMatchesCurrentSearch(attribute) { const rawFilter = ($scope.config.attribute_name || "").trim(); if (!rawFilter) { From d8540fefd3cb30fdcbf35cb6af4d401b76d82619 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 22 Apr 2026 12:28:19 +0200 Subject: [PATCH 110/156] feat: fixed deduplicated template provider --- js/pi-system_treecontroller.js | 71 ++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 7ad426f0..51f87de2 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -762,41 +762,44 @@ app.controller('AfExplorerFormCtrl', [ // TODO: investigate why it is called very frequently $scope.getTemplateGroups = function() { const groupedAttributes = getGroupedAttributesByTemplate(); + console.log("groupedAttributes", groupedAttributes) const templateGroups = groupedAttributes.templateGroups; - const templateGroupsDeduplicated = templateGroups.reduce((acc, group) => { - group.attributes.forEach((attr) => { - const key = attr.title; - - if (!acc[key]) { - acc[key] = { - title: attr.title, - description: attr.description, - attributes: [], - checked: [], - }; - } - - acc[key].attributes.push({ - category_names: attr.category_names, - has_children: attr.has_children, - path: attr.path, - id: attr.id, - url: attr.url, - type: attr.type, - children: attr.children, - expanded: attr.expanded, - searchHighlighted: attr.searchHighlighted, - parent_template_name: attr.parent_template_name, - checked: attr.checked - }); - - acc[key].checked.push(attr.checked) - - }); - - return acc; - }, {}); - return templateGroupsDeduplicated; + console.log("templateGroups", templateGroups) + const templateGroupsDeduplicated = templateGroups.map(templateGroup => { + return templateGroup.attributes.reduce((acc, attr) => { + const key = attr.title; + + if (!acc[key]) { + acc[key] = { + title: attr.title, + description: attr.description, + attributes: [], + checked: [], + }; + } + + acc[key].attributes.push({ + category_names: attr.category_names, + has_children: attr.has_children, + path: attr.path, + id: attr.id, + url: attr.url, + type: attr.type, + children: attr.children, + expanded: attr.expanded, + searchHighlighted: attr.searchHighlighted, + parent_template_name: attr.parent_template_name, + checked: attr.checked + }); + + acc[key].checked.push(attr.checked) + + return acc; + }, {}) + } + ) + console.log("templateGroupsDeduplicated", templateGroupsDeduplicated) + return templateGroups; }; From f892b36d278346c9535cf8cb3035baef37d4110a Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 22 Apr 2026 15:23:54 +0200 Subject: [PATCH 111/156] feat: display unduplicated attribute list (no checkboxes) --- js/pi-system_treecontroller.js | 86 +++++++++++++++++------------ resource/pi-system_af-explorer.html | 30 +++++----- 2 files changed, 69 insertions(+), 47 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 51f87de2..2b466d58 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -762,46 +762,64 @@ app.controller('AfExplorerFormCtrl', [ // TODO: investigate why it is called very frequently $scope.getTemplateGroups = function() { const groupedAttributes = getGroupedAttributesByTemplate(); - console.log("groupedAttributes", groupedAttributes) - const templateGroups = groupedAttributes.templateGroups; - console.log("templateGroups", templateGroups) - const templateGroupsDeduplicated = templateGroups.map(templateGroup => { - return templateGroup.attributes.reduce((acc, attr) => { - const key = attr.title; - - if (!acc[key]) { - acc[key] = { - title: attr.title, - description: attr.description, - attributes: [], - checked: [], - }; - } - - acc[key].attributes.push({ - category_names: attr.category_names, - has_children: attr.has_children, - path: attr.path, - id: attr.id, - url: attr.url, - type: attr.type, - children: attr.children, - expanded: attr.expanded, - searchHighlighted: attr.searchHighlighted, - parent_template_name: attr.parent_template_name, - checked: attr.checked - }); - - acc[key].checked.push(attr.checked) + return groupedAttributes.templateGroups.map(templateGroup => ({ + ...templateGroup, + attributes: templateGroup.attributes.reduce((acc, attr) => { + const key = attr.title; + + if (!acc[key]) { + acc[key] = { + title: attr.title, + description: attr.description, + attributes: [], + checked: [], + paths: [] + }; + } + + acc[key].attributes.push({ + category_names: attr.category_names, + has_children: attr.has_children, + path: attr.path, + id: attr.id, + url: attr.url, + type: attr.type, + children: attr.children, + expanded: attr.expanded, + searchHighlighted: attr.searchHighlighted, + parent_template_name: attr.parent_template_name, + checked: attr.checked + }); + + acc[key].checked.push(attr.checked) + acc[key].paths.push(attr.path) return acc; }, {}) - } + }) ) - console.log("templateGroupsDeduplicated", templateGroupsDeduplicated) - return templateGroups; }; + // TODO: use once checkbox handles partial check + // const CheckboxStatus = Object.freeze({ + // CHECKED: 'CHECKED', + // UNCHECKED: 'UNCHECKED', + // PARTIAL_CHECK: 'PARTIAL_CHECK', + // }); + // + // $scope.getCheckboxStatus = function(checkboxStatuses) { + // if (checkboxStatuses.every(Boolean)) { + // return checkboxStatuses.CHECKED; + // } else if (checkboxStatuses.some(Boolean)) { + // return checkboxStatuses.PARTIAL_CHECK; + // } + // return checkboxStatuses.UNCHECKED; + // } + + $scope.$watch('config.attributeList', function(newVal, oldVal) { + $scope.templateAggregatedAttributes = $scope.getTemplateGroups(); + }, true); + function attributeMatchesCurrentSearch(attribute) { const rawFilter = ($scope.config.attribute_name || "").trim(); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index f2d6928e..a12ccf57 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -129,8 +129,8 @@
              TitleName Description Path
              TitleName Description Path
              - + + @@ -141,22 +141,26 @@ - + - + + + + - - + + + + + - +
              Name Description PathNo templated attributes
              - - {{group.templateName}}
              - -
              {{attribute.title}} {{attribute.description}}{{attribute.path}} + + {{path}}
              +
              +
              From 30a3717f222be0fa4be9cfa40187f7614bc52726 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 22 Apr 2026 17:55:26 +0200 Subject: [PATCH 112/156] refacto: cleanup chaining --- js/pi-system_treecontroller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 2b466d58..219745ea 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -560,7 +560,7 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.updateAttributeToOutput = function(attribute) { - if (!$scope.config || !$scope.config.attributeList) return; + if (!$scope.config?.attributeList) return; const selectedAttributes = $scope.config.selectedAttributes; const attributeList = $scope.config.attributeList; From 18237f5497d90ded8270711f269c8eb1d405dfa1 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Thu, 23 Apr 2026 09:56:43 +0200 Subject: [PATCH 113/156] feat: multiselect checkboxes add to the output dataset --- js/pi-system_treecontroller.js | 31 +++++++++++++---------------- resource/pi-system_af-explorer.html | 20 +++++++++---------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 219745ea..28f0bb30 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -559,6 +559,13 @@ app.controller('AfExplorerFormCtrl', [ setAttributesChecked(group.attributes, shouldCheck); }; + $scope.checkAttribute = function(attributeAggregate) { + attributeAggregate.attributes.forEach((attribute) => { + $scope.updateAttributeToOutput(attribute) + } + ) + }; + $scope.updateAttributeToOutput = function(attribute) { if (!$scope.config?.attributeList) return; @@ -759,7 +766,6 @@ app.controller('AfExplorerFormCtrl', [ return getGroupedAttributesByTemplate().attributesWithoutTemplate; }; - // TODO: investigate why it is called very frequently $scope.getTemplateGroups = function() { const groupedAttributes = getGroupedAttributesByTemplate(); return groupedAttributes.templateGroups.map(templateGroup => ({ @@ -771,28 +777,19 @@ app.controller('AfExplorerFormCtrl', [ acc[key] = { title: attr.title, description: attr.description, + checked: null, + allChecked: attr.checked, attributes: [], - checked: [], + checkStates: [], paths: [] }; } - acc[key].attributes.push({ - category_names: attr.category_names, - has_children: attr.has_children, - path: attr.path, - id: attr.id, - url: attr.url, - type: attr.type, - children: attr.children, - expanded: attr.expanded, - searchHighlighted: attr.searchHighlighted, - parent_template_name: attr.parent_template_name, - checked: attr.checked - }); - - acc[key].checked.push(attr.checked) + acc[key].checkStates.push(attr.checked) acc[key].paths.push(attr.path) + acc[key].allChecked = acc[key].checked && attr.checked + acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); // TODO maybe move out + acc[key].attributes.push(attr); return acc; }, {}) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index a12ccf57..f20fec96 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -129,8 +129,8 @@ - - + @@ -143,17 +143,17 @@ - - - - + - - - - + - + - + - + - - + + From c340985fa5c66b27056c07edd0b914914814f292 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Fri, 24 Apr 2026 14:48:36 +0200 Subject: [PATCH 115/156] feat: working partial checkboxes + todos for cleanup --- js/pi-system_treecontroller.js | 46 +++++++++++++++++++++++------ resource/pi-system_af-explorer.html | 5 ++-- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 67f9ccf9..4ba2586a 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1,5 +1,12 @@ const app = angular.module('piSystemTreeApp.module', []); +//TODO: divide at least into a tree component + a results/right panel component + welcome component +const CheckboxStatus = Object.freeze({ + CHECKED: 'CHECKED', + UNCHECKED: 'UNCHECKED', + PARTIAL_CHECK: 'PARTIAL_CHECK', +}); + app.service('TreeDataService', function() { // This will store the shared tree data this.treeData = []; @@ -78,6 +85,7 @@ app.controller('AfExplorerFormCtrl', [ data.presets.forEach(function(p) { $scope.accessiblePresets.push({ name: "PRESET " + p.name, label: p.name, usable: p.usable, description: p.description }); }); + // TODO: why injection $scope.accessibleParameterSetDescriptions = $scope.accessiblePresets.map(function(p) { return p.description || 'No description'; }); @@ -206,6 +214,7 @@ app.controller('AfExplorerFormCtrl', [ }; let presetWatchInitialized = false; + // TODO: move this to an ng-change $scope.$watchGroup( [ function() { @@ -787,7 +796,7 @@ app.controller('AfExplorerFormCtrl', [ acc[key].checkStates.push(attr.checked) acc[key].paths.push(attr.path) - acc[key].allChecked = acc[key].checked && attr.checked + acc[key].allChecked = acc[key].allChecked && attr.checked acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); // TODO maybe move out acc[key].attributes.push(attr); @@ -798,21 +807,17 @@ app.controller('AfExplorerFormCtrl', [ }; // TODO: use once checkbox handles partial check - const CheckboxStatus = Object.freeze({ - CHECKED: 'CHECKED', - UNCHECKED: 'UNCHECKED', - PARTIAL_CHECK: 'PARTIAL_CHECK', - }); $scope.getCheckboxStatus = function(checkboxStatuses) { if (checkboxStatuses.every(Boolean)) { - return checkboxStatuses.CHECKED; + return CheckboxStatus.CHECKED; } else if (checkboxStatuses.some(Boolean)) { - return checkboxStatuses.PARTIAL_CHECK; + return CheckboxStatus.PARTIAL_CHECK; } - return checkboxStatuses.UNCHECKED; + return CheckboxStatus.UNCHECKED; } + // TODO: try to move it to a callback of some kind (will work with a component) $scope.$watch('config.attributeList', function(newVal, oldVal) { $scope.templateAggregatedAttributes = $scope.getTemplateGroups(); }, true); @@ -1095,3 +1100,26 @@ app.component('treeNode', { ` }); + +app.directive('indeterminate', function() { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs) { + if (attrs.indeterminate === CheckboxStatus.PARTIAL_CHECK) + { + element[0].indeterminate = true; + } + + scope.$watch(attrs.indeterminate, function(checkStatus) { + console.log("Changed check status", checkStatus); + if (checkStatus === CheckboxStatus.PARTIAL_CHECK) + { + element[0].indeterminate = true; + return; + } + element[0].indeterminate = false; + }, true); + } + }; +}); \ No newline at end of file diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 65668eb1..385b77f5 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -152,10 +152,11 @@ - + + - + From c7e241172a391bbfe1dd3aa46928837416047e0c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 09:47:09 +0200 Subject: [PATCH 128/156] feat: improve datatype/aggregate form data structure --- js/pi-system_treecontroller.js | 100 ++++++++++++++++----------------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 85cbd0bf..7a6229dd 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -14,15 +14,15 @@ const aggregateDataTypeFields = Object.freeze({ { value: 'EndValue', label: 'End value' }, ] }, - summary_type: { - label: 'Summary type', - type: 'multiselect', - dependsOn: ['data_type'], - isVisible: function(state) { - return state.data_type === 'SummaryData'; - }, - options: function() { - return [ + aggregates: { + summary_type: { + label: 'Summary type', + type: 'multiselect', + dependsOn: ['data_type'], + isVisible: function(state) { + return state.data_type === 'SummaryData'; + }, + options: [ { value: 'Total', label: 'Total' }, { value: 'Average', label: 'Average' }, { value: 'Minimum', label: 'Minimum' }, @@ -35,59 +35,55 @@ const aggregateDataTypeFields = Object.freeze({ { value: 'TotalWithUOM', label: 'Total with UOM' }, { value: 'All', label: 'All' }, { value: 'AllForNonNumeric', label: 'All for non numeric' }, - ]; + ] }, - }, - boundary_type: { - label: 'Boundary type', - type: 'select', - dependsOn: ['data_type'], - defaultValue: 'Inside', - isVisible: function(state) { - return state.data_type === 'InterpolatedData'; - }, - options: function() { - return [ + boundary_type: { + label: 'Boundary type', + type: 'select', + dependsOn: ['data_type'], + defaultValue: 'Inside', + isVisible: function(state) { + return state.data_type === 'InterpolatedData'; + }, + options: [ { value: 'Inside', label: 'Inside' }, { value: 'Outside', label: 'Outside' }, - ]; - }, - }, - record_boundary_type: { - label: 'Boundary type', - type: 'select', - dependsOn: ['data_type'], - defaultValue: 'Inside', - isVisible: function(state) { - return state.data_type === 'RecordedData'; + ] }, - options: function() { - return [ + record_boundary_type: { + label: 'Boundary type', + type: 'select', + dependsOn: ['data_type'], + defaultValue: 'Inside', + isVisible: function(state) { + return state.data_type === 'RecordedData'; + }, + options: [ { value: 'Inside', label: 'Inside' }, { value: 'Interpolated', label: 'Interpolated' }, { value: 'Outside', label: 'Outside' }, - ]; + ] }, - }, - summary_duration: { - label: 'Summary duration', - type: 'string', - dependsOn: ['data_type'], - defaultValue: '', - isVisible: function(state) { - return state.data_type === 'SummaryData'; + summary_duration: { + label: 'Summary duration', + type: 'string', + dependsOn: ['data_type'], + defaultValue: '', + isVisible: function(state) { + return state.data_type === 'SummaryData'; + }, }, - }, - max_count: { - label: 'Max count', - type: 'int', - dependsOn: ['show_advanced_parameters', 'data_type'], - defaultValue: 10000, - isVisible: function(state) { - return state.show_advanced_parameters === true && - ['PlotData', 'InterpolatedData', 'RecordedData'].includes(state.data_type); + max_count: { + label: 'Max count', + type: 'int', + dependsOn: ['show_advanced_parameters', 'data_type'], + defaultValue: 10000, + isVisible: function(state) { + return state.show_advanced_parameters === true && + ['PlotData', 'InterpolatedData', 'RecordedData'].includes(state.data_type); + }, }, - }, + } }); //TODO: divide at least into a tree component + a results/right panel component + welcome component From 753f787f0e4cf8eb9791ba58a0b7956ce4696d3d Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 09:48:01 +0200 Subject: [PATCH 129/156] cleaning: format --- js/pi-system_treecontroller.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 7a6229dd..d60d352d 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -891,7 +891,7 @@ app.controller('AfExplorerFormCtrl', [ } } - function getCheckboxStatus(checkboxStatuses) { + function getCheckboxStatus(checkboxStatuses) { if (checkboxStatuses.every(Boolean)) { return CheckboxStatus.CHECKED; } else if (checkboxStatuses.some(Boolean)) { @@ -1115,14 +1115,12 @@ app.directive('indeterminate', function() { return { restrict: 'A', link: function(scope, element, attrs) { - if (attrs.indeterminate === CheckboxStatus.PARTIAL_CHECK) - { + if (attrs.indeterminate === CheckboxStatus.PARTIAL_CHECK) { element[0].indeterminate = true; } scope.$watch(attrs.indeterminate, function(checkStatus) { - if (checkStatus === CheckboxStatus.PARTIAL_CHECK) - { + if (checkStatus === CheckboxStatus.PARTIAL_CHECK) { element[0].indeterminate = true; return; } From fce276781f9ce22baae9887ddcf8315ffa0d441c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 12:30:36 +0200 Subject: [PATCH 130/156] feat: aggregate are displayed and saved --- js/pi-system_treecontroller.js | 74 ++++++++++++++++++++--------- resource/pi-system_af-explorer.html | 11 ++++- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index d60d352d..fc9e9166 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -19,8 +19,8 @@ const aggregateDataTypeFields = Object.freeze({ label: 'Summary type', type: 'multiselect', dependsOn: ['data_type'], - isVisible: function(state) { - return state.data_type === 'SummaryData'; + isVisible: function(attribute) { + return attribute.data_type === 'SummaryData'; }, options: [ { value: 'Total', label: 'Total' }, @@ -42,8 +42,8 @@ const aggregateDataTypeFields = Object.freeze({ type: 'select', dependsOn: ['data_type'], defaultValue: 'Inside', - isVisible: function(state) { - return state.data_type === 'InterpolatedData'; + isVisible: function(attribute) { + return attribute.data_type === 'InterpolatedData'; }, options: [ { value: 'Inside', label: 'Inside' }, @@ -55,8 +55,8 @@ const aggregateDataTypeFields = Object.freeze({ type: 'select', dependsOn: ['data_type'], defaultValue: 'Inside', - isVisible: function(state) { - return state.data_type === 'RecordedData'; + isVisible: function(attribute) { + return attribute.data_type === 'RecordedData'; }, options: [ { value: 'Inside', label: 'Inside' }, @@ -69,8 +69,8 @@ const aggregateDataTypeFields = Object.freeze({ type: 'string', dependsOn: ['data_type'], defaultValue: '', - isVisible: function(state) { - return state.data_type === 'SummaryData'; + isVisible: function(attribute) { + return attribute.data_type === 'SummaryData'; }, }, max_count: { @@ -78,9 +78,9 @@ const aggregateDataTypeFields = Object.freeze({ type: 'int', dependsOn: ['show_advanced_parameters', 'data_type'], defaultValue: 10000, - isVisible: function(state) { - return state.show_advanced_parameters === true && - ['PlotData', 'InterpolatedData', 'RecordedData'].includes(state.data_type); + isVisible: function(attribute) { + return attribute.show_advanced_parameters === true && + ['PlotData', 'InterpolatedData', 'RecordedData'].includes(attribute.data_type); }, }, } @@ -493,7 +493,6 @@ app.controller('AfExplorerFormCtrl', [ }; function applySearchAttributesToList(attributes) { - const preservedSelectedPathSet = new Set($scope.config.outputSelectedAttributes.map(attr => attr.path)); const seen = new Set(); const deduped = []; @@ -503,8 +502,7 @@ app.controller('AfExplorerFormCtrl', [ } seen.add(attribute.path); const attrCopy = { ...attribute }; - attrCopy.checked = preservedSelectedPathSet.has(attrCopy.path); - deduped.push(attrCopy); + deduped.push(enrichAttribute(attrCopy)); }); $scope.config.attributeList = deduped; @@ -788,6 +786,17 @@ app.controller('AfExplorerFormCtrl', [ } } + // Merge frontend data and saved output with loaded attributes + function enrichAttribute(attribute) { + const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === attribute.path); + attribute.checked = !!(selectedAttribute); + attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; + getAggregateNames().forEach(aggregateName => { + attribute[aggregateName] = selectedAttribute?.[aggregateName]; + }); + return attribute; + } + function processNode(node) { const hasAttributeFilter = !!($scope.config.attribute_name?.trim()); const parentTemplateName = node?.template_name ? node.template_name : null; @@ -802,13 +811,7 @@ app.controller('AfExplorerFormCtrl', [ } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { - const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === child.path); - if (selectedAttribute) { - child.data_type = selectedAttribute?.data_type; - } else { - child.data_type = $scope.aggregateDataTypeFields.data_type.defaultValue; - } - $scope.config.attributeList.push(child); + $scope.config.attributeList.push(enrichAttribute(child)); } } }); @@ -818,6 +821,14 @@ app.controller('AfExplorerFormCtrl', [ return $scope.getGroupedAttributesByTemplate().attributesWithoutTemplate; }; + function getAggregateNames() { + return Object.keys($scope.aggregateDataTypeFields.aggregates); + } + + function getAggregateValuesKey(aggregateName) { + return aggregateName + 's'; + } + function groupIdenticalAttributes(acc, attr) { const key = attr.parent_template_name + "::" + attr.title; @@ -826,14 +837,19 @@ app.controller('AfExplorerFormCtrl', [ title: attr.title, description: attr.description, templateName: attr.parent_template_name, - data_type: attr.data_type, - data_types: [], checked: null, // Used to determine UI checkbox state allChecked: attr.checked, attributes: [], checkStates: [], paths: [], + data_type: attr.data_type, + data_types: [], }; + + getAggregateNames().forEach(aggregateName => { + acc[key][aggregateName] = attr[aggregateName]; + acc[key][getAggregateValuesKey(aggregateName)] = []; + }); } acc[key].checkStates.push(attr.checked) @@ -847,12 +863,24 @@ app.controller('AfExplorerFormCtrl', [ acc[key].data_type = null; } + getAggregateNames().forEach(aggregateName => { + acc[key][getAggregateValuesKey(aggregateName)].push(attr[aggregateName]); + if (acc[key][aggregateName] !== attr[aggregateName]) { + acc[key][aggregateName] = null; + } + }); + return acc; } $scope.updateMergedAttributeDataType = function(mergedAttribute) { + const aggregateNames = getAggregateNames(); + mergedAttribute.attributes.forEach(attribute => { attribute.data_type = mergedAttribute.data_type; + aggregateNames.forEach(aggregateName => { + attribute[aggregateName] = mergedAttribute[aggregateName]; + }); upsertOutputSelectedAttribute(attribute, attribute.checked); }); }; diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 7fd99975..1e97f5ef 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -173,7 +173,16 @@ ng-change="updateMergedAttributeDataType(mergedAttribute)"> - +
              Name Description Path
              + + {{group.templateName}}
              + + {{attribute.title}} {{attribute.description}} From 30c33c24208fc6a73e6a733a271f6110b2b15073 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Thu, 23 Apr 2026 10:35:07 +0200 Subject: [PATCH 114/156] refacto: rename for clarity template > mergedAttributes > attributes --- js/pi-system_treecontroller.js | 42 ++++++++++++++--------------- resource/pi-system_af-explorer.html | 20 +++++++------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 28f0bb30..67f9ccf9 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -545,22 +545,22 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.isTemplateGroupChecked = function(group) { - if (!group || !Array.isArray(group.attributes) || !group.attributes.length) { + if (!group || !Array.isArray(group?.mergedAttributes) || !group.mergedAttributes.length) { return false; } - return group.attributes.every(attribute => !!attribute.checked); + return group.mergedAttributes.every(attribute => !!attribute.checked); }; $scope.toggleTemplateGroupAttributes = function(group) { - if (!group || !Array.isArray(group.attributes)) { + if (!group || !Array.isArray(group?.mergedAttributes)) { return; } const shouldCheck = !$scope.isTemplateGroupChecked(group); - setAttributesChecked(group.attributes, shouldCheck); + setAttributesChecked(group.mergedAttributes, shouldCheck); }; - $scope.checkAttribute = function(attributeAggregate) { - attributeAggregate.attributes.forEach((attribute) => { + $scope.checkAttribute = function(attributeList) { + attributeList.attributes.forEach((attribute) => { $scope.updateAttributeToOutput(attribute) } ) @@ -770,7 +770,7 @@ app.controller('AfExplorerFormCtrl', [ const groupedAttributes = getGroupedAttributesByTemplate(); return groupedAttributes.templateGroups.map(templateGroup => ({ ...templateGroup, - attributes: templateGroup.attributes.reduce((acc, attr) => { + mergedAttributes: templateGroup.attributes.reduce((acc, attr) => { const key = attr.title; if (!acc[key]) { @@ -798,20 +798,20 @@ app.controller('AfExplorerFormCtrl', [ }; // TODO: use once checkbox handles partial check - // const CheckboxStatus = Object.freeze({ - // CHECKED: 'CHECKED', - // UNCHECKED: 'UNCHECKED', - // PARTIAL_CHECK: 'PARTIAL_CHECK', - // }); - // - // $scope.getCheckboxStatus = function(checkboxStatuses) { - // if (checkboxStatuses.every(Boolean)) { - // return checkboxStatuses.CHECKED; - // } else if (checkboxStatuses.some(Boolean)) { - // return checkboxStatuses.PARTIAL_CHECK; - // } - // return checkboxStatuses.UNCHECKED; - // } + const CheckboxStatus = Object.freeze({ + CHECKED: 'CHECKED', + UNCHECKED: 'UNCHECKED', + PARTIAL_CHECK: 'PARTIAL_CHECK', + }); + + $scope.getCheckboxStatus = function(checkboxStatuses) { + if (checkboxStatuses.every(Boolean)) { + return checkboxStatuses.CHECKED; + } else if (checkboxStatuses.some(Boolean)) { + return checkboxStatuses.PARTIAL_CHECK; + } + return checkboxStatuses.UNCHECKED; + } $scope.$watch('config.attributeList', function(newVal, oldVal) { $scope.templateAggregatedAttributes = $scope.getTemplateGroups(); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index f20fec96..65668eb1 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -141,23 +141,23 @@ No templated attributes
              - + {{group.templateName}}{{template.templateName}}
              - + {{attribute.title}}{{attribute.description}}{{mergedAttribute.title}}{{mergedAttribute.description}} - + {{path}}
              + ng-change="checkAttribute(mergedAttribute)" indeterminate="mergedAttribute.checked"> {{mergedAttribute.title}}{{mergedAttribute.description}}{{mergedAttribute.checked}} {{path}}
              From ff909a75e5ca8c159bf78731f5a1e14bbd5c81b0 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Fri, 24 Apr 2026 14:49:19 +0200 Subject: [PATCH 116/156] refacto: move html template to separate file --- js/pi-system_treecontroller.js | 52 ++-------------------------------- resource/tree-node.html | 46 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 50 deletions(-) create mode 100644 resource/tree-node.html diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 4ba2586a..1197f45b 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1049,56 +1049,8 @@ app.component('treeNode', { ctrl.isSearchResult = function(node) { return !!node.searchHighlighted; }; - }, - - template: ` -
              - - - - - - -
              - - {{ ctrl.node.title }} - -
              - -
              - -
                -
              • - - -
              • -
              - ` + }, // TODO: move this to own html file using templateUrl + templateUrl: "/plugins/pi-system/resource/tree-node.html" }); app.directive('indeterminate', function() { diff --git a/resource/tree-node.html b/resource/tree-node.html new file mode 100644 index 00000000..5d9ebdef --- /dev/null +++ b/resource/tree-node.html @@ -0,0 +1,46 @@ +
              + + + + + + +
              + + {{ ctrl.node.title }} + +
              + +
              + +
                +
              • + + +
              • +
              \ No newline at end of file From 256b862b57c5c2e436fd43b46a9d846537d232dd Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 11:02:03 +0200 Subject: [PATCH 117/156] refacto: move group by template logic to reduce --- js/pi-system_treecontroller.js | 92 ++++++++--------------------- resource/pi-system_af-explorer.html | 60 +++++++++---------- 2 files changed, 54 insertions(+), 98 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 1197f45b..c084f550 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -703,78 +703,34 @@ app.controller('AfExplorerFormCtrl', [ syncVisibleSelectionFromOutput(); } - function getAttributeTemplateName(attribute) { - if (!attribute) { - return ""; - } - const templateName = attribute.parent_template_name || attribute.template_name || ""; - return typeof templateName === "string" ? templateName.trim() : ""; - } - - function groupAttributesByTemplate(attributes) { - const attributesByTemplate = new Map(); - const attributesWithoutTemplate = []; - - attributes.forEach(attribute => { - if (!attribute?.path) { - return; - } - const templateName = getAttributeTemplateName(attribute); - if (!templateName) { - attributesWithoutTemplate.push(attribute); - return; - } - if (!attributesByTemplate.has(templateName)) { - attributesByTemplate.set(templateName, []); - } - attributesByTemplate.get(templateName).push(attribute); - }); - - const templateGroups = []; - attributesByTemplate.forEach((templateAttributes, templateName) => { - templateGroups.push({ - groupKey: "template:" + templateName, - templateName: templateName, - attributes: templateAttributes - }); - }); - - return { - attributesWithoutTemplate: attributesWithoutTemplate, - templateGroups: templateGroups - }; - } - - function buildTemplateGroupingKey(attributes) { - return attributes - .filter(attribute => attribute?.path) - .map(attribute => attribute.path + "::" + getAttributeTemplateName(attribute)) - .join("||"); - } - - let templateGroupingKey = null; - let templateGroupingSource = null; - let groupedAttributesByTemplate = { - attributesWithoutTemplate: [], - templateGroups: [] + $scope.getAttributesWithoutTemplate = function() { + return getGroupedAttributesByTemplate().attributesWithoutTemplate; }; function getGroupedAttributesByTemplate() { - const attributes = Array.isArray($scope.config.attributeList) ? $scope.config.attributeList : []; - const nextTemplateGroupingKey = buildTemplateGroupingKey(attributes); - if (attributes === templateGroupingSource && nextTemplateGroupingKey === templateGroupingKey) { - return groupedAttributesByTemplate; + const templateGroups = $scope.config.attributeList.reduce( + (acc, attr) => { + const key = attr.parent_template_name; // TODO: check it's not template_name ever + if (!acc[key]) { + acc[key] = { + templateName: attr.parent_template_name, + allChecked: attr.checked, + checked: null, // Used to determine UI checkbox state + attributes: [] // TODO: potentially remove when merging attributes + } + } + + acc[key].allChecked = acc[key].allChecked && attr.checked + acc[key].attributes.push(attr); + return acc; + }, {} + ) + return { + attributesWithoutTemplate: [], // TODO remove if does not exist, + templateGroups: Object.values(templateGroups) // TODO: see if can be avoided } - templateGroupingSource = attributes; - templateGroupingKey = nextTemplateGroupingKey; - groupedAttributesByTemplate = groupAttributesByTemplate(attributes); - return groupedAttributesByTemplate; } - $scope.getAttributesWithoutTemplate = function() { - return getGroupedAttributesByTemplate().attributesWithoutTemplate; - }; - $scope.getTemplateGroups = function() { const groupedAttributes = getGroupedAttributesByTemplate(); return groupedAttributes.templateGroups.map(templateGroup => ({ @@ -786,7 +742,7 @@ app.controller('AfExplorerFormCtrl', [ acc[key] = { title: attr.title, description: attr.description, - checked: null, + checked: null, // Used to determine UI checkbox state allChecked: attr.checked, attributes: [], checkStates: [], @@ -1049,7 +1005,7 @@ app.component('treeNode', { ctrl.isSearchResult = function(node) { return !!node.searchHighlighted; }; - }, // TODO: move this to own html file using templateUrl + }, templateUrl: "/plugins/pi-system/resource/tree-node.html" }); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 385b77f5..7c02b1c6 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -95,35 +95,35 @@ -
              - Attributes - free floating, unattached to templates -
              Elements
              - - - - - - - - - - - - - - - - - - - - -
              NameDescriptionPath
              No attributes without template
              - - {{attribute.title}}{{attribute.description}}{{attribute.path}}
              -
              + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
              Templates
              @@ -141,7 +141,7 @@ - + - + From 37492b9475eb86082507c63fdcc568553ef17f84 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 15:16:17 +0200 Subject: [PATCH 120/156] feat: working template level checkboxes --- js/pi-system_treecontroller.js | 57 ++++++++++++++++++++--------- resource/pi-system_af-explorer.html | 2 +- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 08f4da2e..75c46333 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -567,33 +567,39 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.checkAttribute = function(attributeList) { + const shouldRemove = attributeList.checked === CheckboxStatus.CHECKED; attributeList.attributes.forEach((attribute) => { - $scope.updateAttributeToOutput(attribute) + if (shouldRemove) { + $scope.removeAttributeFromOutput(attribute); + return; + } + $scope.addAttributeToOutput(attribute); } ) }; - $scope.checkTemplate = function(aggregatedAttributeList) { - aggregatedAttributeList.forEach((attribute) => { - $scope.checkAttribute(attribute) + $scope.checkTemplate = function(template) { + const shouldRemove = template.checked === CheckboxStatus.CHECKED; + template.attributes.forEach((aggregatedAttribute) => { + aggregatedAttribute.attributes.forEach((underlyingAttribute) => { + if (shouldRemove) { + $scope.removeAttributeFromOutput(underlyingAttribute); + return; + } + $scope.addAttributeToOutput(underlyingAttribute); + }); } ) }; - $scope.updateAttributeToOutput = function(attribute) { + $scope.addAttributeToOutput = function(attribute) { if (!$scope.config?.attributeList) return; const selectedAttributes = $scope.config.selectedAttributes; const attributeList = $scope.config.attributeList; const index = selectedAttributes.findIndex(attr => attr.path === attribute.path); - if (index !== -1) { - selectedAttributes.splice(index, 1); - - const attrInConfig = attributeList.find(attr => attr.path === attribute.path); - if (attrInConfig) attrInConfig.checked = false; - upsertOutputSelectedAttribute(attribute, false); return; } @@ -607,8 +613,27 @@ app.controller('AfExplorerFormCtrl', [ selectedAttributes.push(attribute); attrInConfig.checked = true; upsertOutputSelectedAttribute(attribute, true); - }; + } + + $scope.removeAttributeFromOutput = function(attribute) { + if (!$scope.config?.attributeList) return; + + const selectedAttributes = $scope.config.selectedAttributes; + const attributeList = $scope.config.attributeList; + + const index = selectedAttributes.findIndex(attr => attr.path === attribute.path); + if (index === -1) { + console.warn("Attribute not selected:", attribute.path); + return; + } + + selectedAttributes.splice(index, 1); + + const attrInConfig = attributeList.find(attr => attr.path === attribute.path); + if (attrInConfig) attrInConfig.checked = false; + upsertOutputSelectedAttribute(attribute, false); + } function getNodeAttributePaths(node) { if (!node || !Array.isArray(node.children)) { @@ -735,18 +760,15 @@ app.controller('AfExplorerFormCtrl', [ $scope.getGroupedAttributesByTemplate = function() { const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) - console.log("groupedAttributes", groupedAttributes) const templateGroups = groupedAttributes.reduce( (acc, attr) => { - console.log("attr", attr) - console.log("acc", acc) const key = attr.templateName; // TODO: check it's not template_name ever if (!acc[key]) { acc[key] = { templateName: attr.templateName, allChecked: attr.checked, checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state - attributes: [], // TODO: potentially remove when merging attributes + attributes: [], checkStates: [] } } @@ -758,7 +780,6 @@ app.controller('AfExplorerFormCtrl', [ return acc; }, {} ) - console.log("templateGroups", templateGroups) return { attributesWithoutTemplate: [], // TODO remove if does not exist, templateGroups: templateGroups // TODO: see if can be avoided @@ -1012,4 +1033,4 @@ app.directive('indeterminate', function() { }, true); } }; -}); \ No newline at end of file +}); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index e404ae76..bfdc84b9 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -145,7 +145,7 @@ From d5fc09b8303c5c0e304488514ebeab3ecd0149a6 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 16:04:37 +0200 Subject: [PATCH 121/156] feat: working top level checkbox --- js/pi-system_treecontroller.js | 40 +++++++++++++++++------------ resource/pi-system_af-explorer.html | 8 +++--- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 75c46333..034574ec 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -535,21 +535,21 @@ app.controller('AfExplorerFormCtrl', [ setAttributesChecked($scope.getAttributesWithoutTemplate(), !!$scope.config.selectAllWithoutTemplateAttributes); }; - // function getAllTemplatedAttributes() { - // const templateGroups = $scope.getGroupedAttributesByTemplate().templateGroups; - // const templatedAttributes = []; - // templateGroups.forEach(group => { - // if (!group || !Array.isArray(group.attributes)) { - // return; - // } - // templatedAttributes.push.apply(templatedAttributes, group.attributes); - // }); - // return templatedAttributes; - // } - - // $scope.toggleSelectAllTemplateAttributes = function() { - // setAttributesChecked(getAllTemplatedAttributes(), !!$scope.config.selectAllTemplateAttributes); - // }; + $scope.toggleSelectAllTemplateAttributes = function() { + const shouldRemove = $scope.templateAggregatedAttributes.checked === CheckboxStatus.CHECKED; + $scope.templateAggregatedAttributes.templates.forEach((template) => { + template.attributes.forEach((aggregatedAttribute) => { + aggregatedAttribute.attributes.forEach((underlyingAttribute) => { + if (shouldRemove) { + $scope.removeAttributeFromOutput(underlyingAttribute); + return; + } + $scope.addAttributeToOutput(underlyingAttribute); + }); + }); + } + ) + }; $scope.isTemplateGroupChecked = function(group) { if (!group || !Array.isArray(group?.mergedAttributes) || !group.mergedAttributes.length) { @@ -760,7 +760,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.getGroupedAttributesByTemplate = function() { const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) - const templateGroups = groupedAttributes.reduce( + const groupedTemplates = Object.values(groupedAttributes.reduce( (acc, attr) => { const key = attr.templateName; // TODO: check it's not template_name ever if (!acc[key]) { @@ -779,7 +779,13 @@ app.controller('AfExplorerFormCtrl', [ acc[key].attributes.push(attr); return acc; }, {} - ) + )); + const templateGroups = { + allChecked: groupedTemplates.every(template => template.allChecked), + checked: getCheckboxStatus(groupedTemplates.reduce((acc, arr) => acc.concat(arr.checkStates), [])), + templates: groupedTemplates + } + console.log("templateGroups", templateGroups); return { attributesWithoutTemplate: [], // TODO remove if does not exist, templateGroups: templateGroups // TODO: see if can be avoided diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index bfdc84b9..bad19b94 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -129,19 +129,19 @@
              No templated attributes
              Date: Mon, 27 Apr 2026 13:44:13 +0200 Subject: [PATCH 118/156] refacto: building template aggregated attribute object --- js/pi-system_treecontroller.js | 77 ++++++++++++++--------------- resource/pi-system_af-explorer.html | 6 +-- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c084f550..06611bb4 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -538,7 +538,7 @@ app.controller('AfExplorerFormCtrl', [ }; function getAllTemplatedAttributes() { - const templateGroups = getGroupedAttributesByTemplate().templateGroups; + const templateGroups = $scope.getGroupedAttributesByTemplate().templateGroups; const templatedAttributes = []; templateGroups.forEach(group => { if (!group || !Array.isArray(group.attributes)) { @@ -707,61 +707,60 @@ app.controller('AfExplorerFormCtrl', [ return getGroupedAttributesByTemplate().attributesWithoutTemplate; }; - function getGroupedAttributesByTemplate() { - const templateGroups = $scope.config.attributeList.reduce( + function groupIdenticalAttributes(acc, attr) { + const key = attr.parent_template_name + "::" + attr.title; + + if (!acc[key]) { + acc[key] = { + title: attr.title, + description: attr.description, + templateName: attr.parent_template_name, + checked: null, // Used to determine UI checkbox state + allChecked: attr.checked, + attributes: [], + checkStates: [], + paths: [], + }; + } + + acc[key].checkStates.push(attr.checked) + acc[key].paths.push(attr.path) + acc[key].allChecked = acc[key].allChecked && attr.checked + acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); // TODO maybe move out + acc[key].attributes.push(attr); + + return acc; + } + + $scope.getGroupedAttributesByTemplate = function() { + const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) + + const templateGroups = groupedAttributes.reduce( (acc, attr) => { - const key = attr.parent_template_name; // TODO: check it's not template_name ever + const key = attr.templateName; // TODO: check it's not template_name ever if (!acc[key]) { acc[key] = { - templateName: attr.parent_template_name, + templateName: attr.templateName, allChecked: attr.checked, checked: null, // Used to determine UI checkbox state - attributes: [] // TODO: potentially remove when merging attributes + attributes: [], // TODO: potentially remove when merging attributes + checkStates: [] } } acc[key].allChecked = acc[key].allChecked && attr.checked + acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); acc[key].attributes.push(attr); + acc[key].checkStates.push(attr.checked) return acc; }, {} ) return { attributesWithoutTemplate: [], // TODO remove if does not exist, - templateGroups: Object.values(templateGroups) // TODO: see if can be avoided + templateGroups: templateGroups // TODO: see if can be avoided } } - $scope.getTemplateGroups = function() { - const groupedAttributes = getGroupedAttributesByTemplate(); - return groupedAttributes.templateGroups.map(templateGroup => ({ - ...templateGroup, - mergedAttributes: templateGroup.attributes.reduce((acc, attr) => { - const key = attr.title; - - if (!acc[key]) { - acc[key] = { - title: attr.title, - description: attr.description, - checked: null, // Used to determine UI checkbox state - allChecked: attr.checked, - attributes: [], - checkStates: [], - paths: [] - }; - } - - acc[key].checkStates.push(attr.checked) - acc[key].paths.push(attr.path) - acc[key].allChecked = acc[key].allChecked && attr.checked - acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); // TODO maybe move out - acc[key].attributes.push(attr); - - return acc; - }, {}) - }) - ) - }; - // TODO: use once checkbox handles partial check $scope.getCheckboxStatus = function(checkboxStatuses) { @@ -775,7 +774,7 @@ app.controller('AfExplorerFormCtrl', [ // TODO: try to move it to a callback of some kind (will work with a component) $scope.$watch('config.attributeList', function(newVal, oldVal) { - $scope.templateAggregatedAttributes = $scope.getTemplateGroups(); + $scope.templateAggregatedAttributes = $scope.getGroupedAttributesByTemplate().templateGroups; }, true); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 7c02b1c6..602a43d2 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -144,12 +144,12 @@
              - + + {{template.templateName}}
              From 1281a196f8f24448311b2a4823205cf95f8016f4 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 14:32:57 +0200 Subject: [PATCH 119/156] feat: template level checkbox status + remove syncwithvisibleoutput --- js/pi-system_treecontroller.js | 83 ++++++++++++----------------- resource/pi-system_af-explorer.html | 4 +- 2 files changed, 35 insertions(+), 52 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 06611bb4..08f4da2e 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -419,7 +419,6 @@ app.controller('AfExplorerFormCtrl', [ }); $scope.config.attributeList = deduped; - syncVisibleSelectionFromOutput(); } function getMatchedElementPaths(attributes) { @@ -530,28 +529,27 @@ app.controller('AfExplorerFormCtrl', [ attribute.checked = !!isChecked; upsertOutputSelectedAttribute(attribute, !!isChecked); }); - syncVisibleSelectionFromOutput(); } $scope.toggleSelectAllWithoutTemplateAttributes = function() { setAttributesChecked($scope.getAttributesWithoutTemplate(), !!$scope.config.selectAllWithoutTemplateAttributes); }; - function getAllTemplatedAttributes() { - const templateGroups = $scope.getGroupedAttributesByTemplate().templateGroups; - const templatedAttributes = []; - templateGroups.forEach(group => { - if (!group || !Array.isArray(group.attributes)) { - return; - } - templatedAttributes.push.apply(templatedAttributes, group.attributes); - }); - return templatedAttributes; - } - - $scope.toggleSelectAllTemplateAttributes = function() { - setAttributesChecked(getAllTemplatedAttributes(), !!$scope.config.selectAllTemplateAttributes); - }; + // function getAllTemplatedAttributes() { + // const templateGroups = $scope.getGroupedAttributesByTemplate().templateGroups; + // const templatedAttributes = []; + // templateGroups.forEach(group => { + // if (!group || !Array.isArray(group.attributes)) { + // return; + // } + // templatedAttributes.push.apply(templatedAttributes, group.attributes); + // }); + // return templatedAttributes; + // } + + // $scope.toggleSelectAllTemplateAttributes = function() { + // setAttributesChecked(getAllTemplatedAttributes(), !!$scope.config.selectAllTemplateAttributes); + // }; $scope.isTemplateGroupChecked = function(group) { if (!group || !Array.isArray(group?.mergedAttributes) || !group.mergedAttributes.length) { @@ -575,6 +573,13 @@ app.controller('AfExplorerFormCtrl', [ ) }; + $scope.checkTemplate = function(aggregatedAttributeList) { + aggregatedAttributeList.forEach((attribute) => { + $scope.checkAttribute(attribute) + } + ) + }; + $scope.updateAttributeToOutput = function(attribute) { if (!$scope.config?.attributeList) return; @@ -589,7 +594,6 @@ app.controller('AfExplorerFormCtrl', [ const attrInConfig = attributeList.find(attr => attr.path === attribute.path); if (attrInConfig) attrInConfig.checked = false; upsertOutputSelectedAttribute(attribute, false); - syncVisibleSelectionFromOutput(); return; } @@ -603,7 +607,6 @@ app.controller('AfExplorerFormCtrl', [ selectedAttributes.push(attribute); attrInConfig.checked = true; upsertOutputSelectedAttribute(attribute, true); - syncVisibleSelectionFromOutput(); }; @@ -628,7 +631,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectedAttributes = ($scope.config.selectedAttributes || []).filter( attr => !attributePaths.includes(attr.path) ); - syncVisibleSelectionFromOutput(); } // TODO: double check the logic @@ -700,11 +702,10 @@ app.controller('AfExplorerFormCtrl', [ } }); - syncVisibleSelectionFromOutput(); } $scope.getAttributesWithoutTemplate = function() { - return getGroupedAttributesByTemplate().attributesWithoutTemplate; + return $scope.getGroupedAttributesByTemplate().attributesWithoutTemplate; }; function groupIdenticalAttributes(acc, attr) { @@ -725,8 +726,8 @@ app.controller('AfExplorerFormCtrl', [ acc[key].checkStates.push(attr.checked) acc[key].paths.push(attr.path) + acc[key].checked = getCheckboxStatus(acc[key].checkStates); // TODO maybe move out acc[key].allChecked = acc[key].allChecked && attr.checked - acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); // TODO maybe move out acc[key].attributes.push(attr); return acc; @@ -734,36 +735,37 @@ app.controller('AfExplorerFormCtrl', [ $scope.getGroupedAttributesByTemplate = function() { const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) - + console.log("groupedAttributes", groupedAttributes) const templateGroups = groupedAttributes.reduce( (acc, attr) => { + console.log("attr", attr) + console.log("acc", acc) const key = attr.templateName; // TODO: check it's not template_name ever if (!acc[key]) { acc[key] = { templateName: attr.templateName, allChecked: attr.checked, - checked: null, // Used to determine UI checkbox state + checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state attributes: [], // TODO: potentially remove when merging attributes checkStates: [] } } - acc[key].allChecked = acc[key].allChecked && attr.checked - acc[key].checked = $scope.getCheckboxStatus(acc[key].checkStates); + acc[key].checkStates.push(...attr.checkStates) + acc[key].checked = getCheckboxStatus(acc[key].checkStates); + acc[key].allChecked = acc[key].allChecked && attr.allChecked; acc[key].attributes.push(attr); - acc[key].checkStates.push(attr.checked) return acc; }, {} ) + console.log("templateGroups", templateGroups) return { attributesWithoutTemplate: [], // TODO remove if does not exist, templateGroups: templateGroups // TODO: see if can be avoided } } - // TODO: use once checkbox handles partial check - - $scope.getCheckboxStatus = function(checkboxStatuses) { + function getCheckboxStatus(checkboxStatuses) { if (checkboxStatuses.every(Boolean)) { return CheckboxStatus.CHECKED; } else if (checkboxStatuses.some(Boolean)) { @@ -825,24 +827,6 @@ app.controller('AfExplorerFormCtrl', [ outputSelectedAttributes.splice(index, 1); } } - - function syncVisibleSelectionFromOutput() { - const outputSelectedPathSet = new Set(getOutputSelectedAttributes().map(attr => attr.path)); - $scope.config.attributeList.forEach(attr => { - attr.checked = outputSelectedPathSet.has(attr.path); - }); - $scope.config.selectedAttributes = $scope.config.attributeList.filter(attr => attr.checked); - const attributesWithoutTemplate = getGroupedAttributesByTemplate().attributesWithoutTemplate; - $scope.config.selectAllWithoutTemplateAttributes = - attributesWithoutTemplate.length > 0 && - attributesWithoutTemplate.every(attribute => !!attribute.checked); - const templatedAttributes = getAllTemplatedAttributes(); - $scope.config.selectAllTemplateAttributes = - templatedAttributes.length > 0 && - templatedAttributes.every(attribute => !!attribute.checked); - } - - }]); @@ -1011,7 +995,6 @@ app.component('treeNode', { app.directive('indeterminate', function() { return { restrict: 'A', - require: 'ngModel', link: function(scope, element, attrs) { if (attrs.indeterminate === CheckboxStatus.PARTIAL_CHECK) { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 602a43d2..e404ae76 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -144,8 +144,8 @@
              - - + {{template.templateName}}
              + ng-change="checkTemplate(template)" indeterminate="template.checked"> {{template.templateName}}
              - + - + - + - - + - + + + - + @@ -147,7 +153,7 @@ - + + +
              Name Description Path
              No templated attributes
              Date: Mon, 27 Apr 2026 16:16:46 +0200 Subject: [PATCH 122/156] cleanup --- js/pi-system_treecontroller.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 034574ec..5d7da15b 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -164,6 +164,7 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.treeData = []; $scope.config.clickedNodes = []; $scope.config.attributeList = []; + // TODO inspect whether config.selectedAttributes is still needed. $scope.config.selectedAttributes = []; $scope.config.outputSelectedAttributes = []; $scope.config.searchMatchedElementPaths = []; @@ -551,18 +552,11 @@ app.controller('AfExplorerFormCtrl', [ ) }; - $scope.isTemplateGroupChecked = function(group) { - if (!group || !Array.isArray(group?.mergedAttributes) || !group.mergedAttributes.length) { - return false; - } - return group.mergedAttributes.every(attribute => !!attribute.checked); - }; - $scope.toggleTemplateGroupAttributes = function(group) { if (!group || !Array.isArray(group?.mergedAttributes)) { return; } - const shouldCheck = !$scope.isTemplateGroupChecked(group); + const shouldCheck = !group.mergedAttributes.every(attribute => !!attribute.checked); setAttributesChecked(group.mergedAttributes, shouldCheck); }; From 84f872cde7e1d6887dbf7ddc22deb09481880b7a Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 17:28:59 +0200 Subject: [PATCH 123/156] cleanup: remove log --- js/pi-system_treecontroller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 5d7da15b..c14287e9 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1023,7 +1023,6 @@ app.directive('indeterminate', function() { } scope.$watch(attrs.indeterminate, function(checkStatus) { - console.log("Changed check status", checkStatus); if (checkStatus === CheckboxStatus.PARTIAL_CHECK) { element[0].indeterminate = true; From a957e20fb96149010604fef90a2f7d7f507d69ea Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 17:34:33 +0200 Subject: [PATCH 124/156] cleanup: display description, not debug status --- resource/pi-system_af-explorer.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index bad19b94..bf534f5c 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -155,8 +155,7 @@ ng-change="checkAttribute(mergedAttribute)" indeterminate="mergedAttribute.checked"> {{mergedAttribute.title}}{{mergedAttribute.checked}}{{mergedAttribute.description}} {{path}}
              From 151564645c1a1f7471aec9f1965aee21cb66b364 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 27 Apr 2026 17:52:11 +0200 Subject: [PATCH 125/156] feat: attributes and data_type columns --- resource/pi-system_af-explorer.html | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index bf534f5c..8f464b5c 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -106,11 +106,13 @@ + + - + @@ -120,6 +122,8 @@ + + @@ -131,14 +135,16 @@
              NameTitle Description PathData typeAggregates
              No templated attributesNo templated attributes
              {{template.templateName}}{{template.templateName}}
              @@ -161,6 +167,8 @@ {{path}}
              {{mergedAttribute.data_type}}{{mergedAttribute.aggregates}}
              From a0f5d610d07d9cd581dd28f13cf413e399325d95 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 07:59:34 +0200 Subject: [PATCH 126/156] refacto: remove extra getter --- js/pi-system_treecontroller.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c14287e9..7c8db951 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -405,7 +405,7 @@ app.controller('AfExplorerFormCtrl', [ }; function applySearchAttributesToList(attributes) { - const preservedSelectedPathSet = new Set(getOutputSelectedAttributes().map(attr => attr.path)); + const preservedSelectedPathSet = new Set($scope.config.outputSelectedAttributes.map(attr => attr.path)); const seen = new Set(); const deduped = []; @@ -701,7 +701,7 @@ app.controller('AfExplorerFormCtrl', [ } function processNode(node) { - const selectedPaths = new Set(getOutputSelectedAttributes().map(attr => attr.path)); + const selectedPaths = new Set($scope.config.outputSelectedAttributes.map(attr => attr.path)); const hasAttributeFilter = !!($scope.config.attribute_name?.trim()); const parentTemplateName = node?.template_name ? node.template_name : null; @@ -716,6 +716,9 @@ app.controller('AfExplorerFormCtrl', [ const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { child.checked = selectedPaths.has(child.path); + if (child.checked) { + + } $scope.config.attributeList.push(child); } } @@ -822,19 +825,12 @@ app.controller('AfExplorerFormCtrl', [ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - function getOutputSelectedAttributes() { - if (!Array.isArray($scope.config.outputSelectedAttributes)) { - $scope.config.outputSelectedAttributes = []; - } - return $scope.config.outputSelectedAttributes; - } - function upsertOutputSelectedAttribute(attribute, isChecked) { if (!attribute || !attribute.path) { return; } - const outputSelectedAttributes = getOutputSelectedAttributes(); + const outputSelectedAttributes = $scope.config.outputSelectedAttributes; const index = outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); if (isChecked) { From 4d291ab1de7c03bd51a9c70b770de606d73c436c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 09:19:37 +0200 Subject: [PATCH 127/156] feat: data type dropdown --- js/pi-system_treecontroller.js | 116 ++++++++++++++++++++++++++-- resource/pi-system_af-explorer.html | 7 +- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 7c8db951..85cbd0bf 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1,5 +1,95 @@ const app = angular.module('piSystemTreeApp.module', []); +const aggregateDataTypeFields = Object.freeze({ + data_type: { + label: 'Data type', + type: 'select', + defaultValue: 'RecordedData', + options: [ + { value: 'InterpolatedData', label: 'Interpolated' }, + { value: 'PlotData', label: 'Plot' }, + { value: 'RecordedData', label: 'Recorded' }, + { value: 'SummaryData', label: 'Summary' }, + { value: 'Value', label: 'Value' }, + { value: 'EndValue', label: 'End value' }, + ] + }, + summary_type: { + label: 'Summary type', + type: 'multiselect', + dependsOn: ['data_type'], + isVisible: function(state) { + return state.data_type === 'SummaryData'; + }, + options: function() { + return [ + { value: 'Total', label: 'Total' }, + { value: 'Average', label: 'Average' }, + { value: 'Minimum', label: 'Minimum' }, + { value: 'Maximum', label: 'Maximum' }, + { value: 'Range', label: 'Range' }, + { value: 'StdDev', label: 'Standard deviation' }, + { value: 'PopulationStdDev', label: 'Population standard deviation' }, + { value: 'Count', label: 'Count' }, + { value: 'PercentGood', label: 'Percent good' }, + { value: 'TotalWithUOM', label: 'Total with UOM' }, + { value: 'All', label: 'All' }, + { value: 'AllForNonNumeric', label: 'All for non numeric' }, + ]; + }, + }, + boundary_type: { + label: 'Boundary type', + type: 'select', + dependsOn: ['data_type'], + defaultValue: 'Inside', + isVisible: function(state) { + return state.data_type === 'InterpolatedData'; + }, + options: function() { + return [ + { value: 'Inside', label: 'Inside' }, + { value: 'Outside', label: 'Outside' }, + ]; + }, + }, + record_boundary_type: { + label: 'Boundary type', + type: 'select', + dependsOn: ['data_type'], + defaultValue: 'Inside', + isVisible: function(state) { + return state.data_type === 'RecordedData'; + }, + options: function() { + return [ + { value: 'Inside', label: 'Inside' }, + { value: 'Interpolated', label: 'Interpolated' }, + { value: 'Outside', label: 'Outside' }, + ]; + }, + }, + summary_duration: { + label: 'Summary duration', + type: 'string', + dependsOn: ['data_type'], + defaultValue: '', + isVisible: function(state) { + return state.data_type === 'SummaryData'; + }, + }, + max_count: { + label: 'Max count', + type: 'int', + dependsOn: ['show_advanced_parameters', 'data_type'], + defaultValue: 10000, + isVisible: function(state) { + return state.show_advanced_parameters === true && + ['PlotData', 'InterpolatedData', 'RecordedData'].includes(state.data_type); + }, + }, +}); + //TODO: divide at least into a tree component + a results/right panel component + welcome component const CheckboxStatus = Object.freeze({ CHECKED: 'CHECKED', @@ -53,6 +143,8 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectAllWithoutTemplateAttributes = $scope.config.selectAllWithoutTemplateAttributes || false; // select all des attributs standalone $scope.config.selectAllTemplateAttributes = $scope.config.selectAllTemplateAttributes || false; // select all des attributs groupés par template + $scope.aggregateDataTypeFields = aggregateDataTypeFields; + $scope.onAdvancedToggle = function() { if (!$scope.config.show_advanced_parameters) { $scope.config.is_ssl_check_disabled = false; @@ -701,7 +793,6 @@ app.controller('AfExplorerFormCtrl', [ } function processNode(node) { - const selectedPaths = new Set($scope.config.outputSelectedAttributes.map(attr => attr.path)); const hasAttributeFilter = !!($scope.config.attribute_name?.trim()); const parentTemplateName = node?.template_name ? node.template_name : null; @@ -715,15 +806,16 @@ app.controller('AfExplorerFormCtrl', [ } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { - child.checked = selectedPaths.has(child.path); - if (child.checked) { - + const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === child.path); + if (selectedAttribute) { + child.data_type = selectedAttribute?.data_type; + } else { + child.data_type = $scope.aggregateDataTypeFields.data_type.defaultValue; } $scope.config.attributeList.push(child); } } }); - } $scope.getAttributesWithoutTemplate = function() { @@ -738,6 +830,8 @@ app.controller('AfExplorerFormCtrl', [ title: attr.title, description: attr.description, templateName: attr.parent_template_name, + data_type: attr.data_type, + data_types: [], checked: null, // Used to determine UI checkbox state allChecked: attr.checked, attributes: [], @@ -751,10 +845,22 @@ app.controller('AfExplorerFormCtrl', [ acc[key].checked = getCheckboxStatus(acc[key].checkStates); // TODO maybe move out acc[key].allChecked = acc[key].allChecked && attr.checked acc[key].attributes.push(attr); + acc[key].data_types.push(attr.data_type); + + if (acc[key].data_type !== attr.data_type) { + acc[key].data_type = null; + } return acc; } + $scope.updateMergedAttributeDataType = function(mergedAttribute) { + mergedAttribute.attributes.forEach(attribute => { + attribute.data_type = mergedAttribute.data_type; + upsertOutputSelectedAttribute(attribute, attribute.checked); + }); + }; + $scope.getGroupedAttributesByTemplate = function() { const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) const groupedTemplates = Object.values(groupedAttributes.reduce( diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 8f464b5c..7fd99975 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -167,7 +167,12 @@ {{path}}
              {{mergedAttribute.data_type}} + + {{mergedAttribute.aggregates}}
              {{mergedAttribute.aggregates}} +
              + {{aggregate.label}} + +
              +
              From cb0da2aa6f7b0c2e00bbceabf0477b220f09e19c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 15:08:47 +0200 Subject: [PATCH 131/156] feat: handle different types of input for aggregates --- js/pi-system_treecontroller.js | 9 ++++----- resource/pi-system_af-explorer.html | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index fc9e9166..8fee1086 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -66,7 +66,7 @@ const aggregateDataTypeFields = Object.freeze({ }, summary_duration: { label: 'Summary duration', - type: 'string', + type: 'text', dependsOn: ['data_type'], defaultValue: '', isVisible: function(attribute) { @@ -75,12 +75,11 @@ const aggregateDataTypeFields = Object.freeze({ }, max_count: { label: 'Max count', - type: 'int', - dependsOn: ['show_advanced_parameters', 'data_type'], + type: 'number', + dependsOn: ['data_type'], defaultValue: 10000, isVisible: function(attribute) { - return attribute.show_advanced_parameters === true && - ['PlotData', 'InterpolatedData', 'RecordedData'].includes(attribute.data_type); + return ['PlotData', 'InterpolatedData', 'RecordedData'].includes(attribute.data_type); }, }, } diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 1e97f5ef..fbe35c00 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -179,8 +179,26 @@ {{aggregate.label}} + + +
              From e6316cf67abf1fb135415cea5c1348260f2c6723 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 15:45:04 +0200 Subject: [PATCH 132/156] feat: working multiselect aggregate with multiselect elements --- js/pi-system_treecontroller.js | 14 ++++++++++++++ resource/pi-system_af-explorer.html | 2 -- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 8fee1086..21b9a82e 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -828,6 +828,14 @@ app.controller('AfExplorerFormCtrl', [ return aggregateName + 's'; } + function stringArraysEqual(a, b) { + if (!a || !b) { + return false; + } + return a.length === b.length && + [...a].sort().every((v, i) => v === [...b].sort()[i]); + } + function groupIdenticalAttributes(acc, attr) { const key = attr.parent_template_name + "::" + attr.title; @@ -864,6 +872,12 @@ app.controller('AfExplorerFormCtrl', [ getAggregateNames().forEach(aggregateName => { acc[key][getAggregateValuesKey(aggregateName)].push(attr[aggregateName]); + if ($scope.aggregateDataTypeFields.aggregates[aggregateName].type === 'multiselect') { + if (!stringArraysEqual(acc[key][aggregateName], attr[aggregateName])) { + acc[key][aggregateName] = []; + } + return; + } if (acc[key][aggregateName] !== attr[aggregateName]) { acc[key][aggregateName] = null; } diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index fbe35c00..f6c9a132 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -190,12 +190,10 @@ > From 6a6c5156f90014c88aadb8966e3055fed1793595 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 28 Apr 2026 16:02:13 +0200 Subject: [PATCH 133/156] feat: add data_type/aggregate values to output --- custom-recipes/pi-system-af-tree/recipe.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 6ada93bd..01c6c79a 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -56,8 +56,13 @@ def next_tree_item(tree_data): {'name': 'paths', 'type': 'string'}, {'name': 'id', 'type': 'string'}, {'name': 'url', 'type': 'string'}, - {'name': 'checked', 'type': 'boolean'}, - {'name': 'expanded', 'type': 'boolean'}, + {'name': 'data_type', 'type': 'string'}, + {'name': 'summary_type', 'type': 'array'}, + {'name': 'boundary_type', 'type': 'string'}, + {'name': 'record_boundary_type', 'type': 'string'}, + {'name': 'summary_duration', 'type': 'string'}, + {'name': 'max_count', 'type': 'int'}, + ] output_dataset.write_schema(schema) From 3acbfbe1b6e5832c2496b74a11d59649e8015178 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 29 Apr 2026 16:23:08 +0200 Subject: [PATCH 134/156] fix: uncheck not working + removed all references to selectedAttributes --- custom-recipes/pi-system-af-tree/recipe.py | 2 +- js/pi-system_treecontroller.js | 131 ++++++++------------- 2 files changed, 53 insertions(+), 80 deletions(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 01c6c79a..29ea6f38 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -66,7 +66,7 @@ def next_tree_item(tree_data): ] output_dataset.write_schema(schema) -selectedAttributes = config.get("outputSelectedAttributes", config.get("selectedAttributes", [])) +selectedAttributes = config.get("outputSelectedAttributes") with output_dataset.get_writer() as writer: for item in selectedAttributes: if item.get("checked", True) is True: diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 21b9a82e..3d461487 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -129,7 +129,6 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.config.attributeList = $scope.config.attributeList || []; // la liste des attributs qui sont affichés sur le main panel à droite - $scope.config.selectedAttributes = $scope.config.selectedAttributes || []; // la liste des attributs qui sont sélectionnés (checkbox cochée) parmi ceux affichés $scope.config.outputSelectedAttributes = $scope.config.outputSelectedAttributes || []; // la liste des attributs qui sont séléctionnés pour être dans l'output dataset $scope.config.searchMatchedElementPaths = $scope.config.searchMatchedElementPaths || []; // la liste pour highlighter les elements de la recherche $scope.config.lastSearchedElementName = $scope.config.lastSearchedElementName || ""; @@ -251,8 +250,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.treeData = []; $scope.config.clickedNodes = []; $scope.config.attributeList = []; - // TODO inspect whether config.selectedAttributes is still needed. - $scope.config.selectedAttributes = []; $scope.config.outputSelectedAttributes = []; $scope.config.searchMatchedElementPaths = []; $scope.config.lastSearchedElementName = ""; @@ -273,7 +270,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.element_categories = []; $scope.config.loadedDatabaseName = null; $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; $scope.config.outputSelectedAttributes = []; $scope.showTreeData = false; $scope.cleanTree(); @@ -386,7 +382,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attribute_name = ""; $scope.config.clickedNodes = []; $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; $scope.config.selectAllWithoutTemplateAttributes = false; $scope.config.selectAllTemplateAttributes = false; $scope.config.searchMatchedElementPaths = []; @@ -466,12 +461,12 @@ app.controller('AfExplorerFormCtrl', [ if (!isRestrictedAttributeSearch) { $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; // Right-side display is reset: both table-level select-all checkboxes must be cleared. $scope.config.selectAllWithoutTemplateAttributes = false; $scope.config.selectAllTemplateAttributes = false; } $scope.config.searchMatchedElementPaths = []; + // TODO: understand what this does $scope.callPythonDo({ method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData }).then( function(data) { TreeDataService.setTreeData(data.choices); @@ -609,11 +604,12 @@ app.controller('AfExplorerFormCtrl', [ return; } attributes.forEach(attribute => { - if (!attribute?.path) { - return; - } attribute.checked = !!isChecked; - upsertOutputSelectedAttribute(attribute, !!isChecked); + if (isChecked) { + $scope.addAttributeToSelection(attribute); + } else { + $scope.removeAttributeFromSelection(attribute); + } }); } @@ -627,10 +623,10 @@ app.controller('AfExplorerFormCtrl', [ template.attributes.forEach((aggregatedAttribute) => { aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { - $scope.removeAttributeFromOutput(underlyingAttribute); + $scope.removeAttributeFromSelection(underlyingAttribute); return; } - $scope.addAttributeToOutput(underlyingAttribute); + $scope.addAttributeToSelection(underlyingAttribute); }); }); } @@ -649,10 +645,10 @@ app.controller('AfExplorerFormCtrl', [ const shouldRemove = attributeList.checked === CheckboxStatus.CHECKED; attributeList.attributes.forEach((attribute) => { if (shouldRemove) { - $scope.removeAttributeFromOutput(attribute); + $scope.removeAttributeFromSelection(attribute); return; } - $scope.addAttributeToOutput(attribute); + $scope.addAttributeToSelection(attribute); } ) }; @@ -662,58 +658,15 @@ app.controller('AfExplorerFormCtrl', [ template.attributes.forEach((aggregatedAttribute) => { aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { - $scope.removeAttributeFromOutput(underlyingAttribute); + $scope.removeAttributeFromSelection(underlyingAttribute); return; } - $scope.addAttributeToOutput(underlyingAttribute); + $scope.addAttributeToSelection(underlyingAttribute); }); } ) }; - $scope.addAttributeToOutput = function(attribute) { - if (!$scope.config?.attributeList) return; - - const selectedAttributes = $scope.config.selectedAttributes; - const attributeList = $scope.config.attributeList; - - const index = selectedAttributes.findIndex(attr => attr.path === attribute.path); - if (index !== -1) { - return; - } - - const attrInConfig = attributeList.find(attr => attr.path === attribute.path); - - if (!attrInConfig) { - console.warn("Attribute not found in config:", attribute.path); - return; - } - - selectedAttributes.push(attribute); - attrInConfig.checked = true; - upsertOutputSelectedAttribute(attribute, true); - } - - $scope.removeAttributeFromOutput = function(attribute) { - if (!$scope.config?.attributeList) return; - - const selectedAttributes = $scope.config.selectedAttributes; - const attributeList = $scope.config.attributeList; - - const index = selectedAttributes.findIndex(attr => attr.path === attribute.path); - - if (index === -1) { - console.warn("Attribute not selected:", attribute.path); - return; - } - - selectedAttributes.splice(index, 1); - - const attrInConfig = attributeList.find(attr => attr.path === attribute.path); - if (attrInConfig) attrInConfig.checked = false; - upsertOutputSelectedAttribute(attribute, false); - } - function getNodeAttributePaths(node) { if (!node || !Array.isArray(node.children)) { return []; @@ -732,9 +685,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attributeList = ($scope.config.attributeList || []).filter( attr => !attributePaths.includes(attr.path) ); - $scope.config.selectedAttributes = ($scope.config.selectedAttributes || []).filter( - attr => !attributePaths.includes(attr.path) - ); } // TODO: double check the logic @@ -766,7 +716,6 @@ app.controller('AfExplorerFormCtrl', [ if (!selectedTemplateNames.length) { $scope.config.template = "-- Any --"; $scope.config.attributeList = []; - $scope.config.selectedAttributes = []; $scope.config.selectAllWithoutTemplateAttributes = false; $scope.config.selectAllTemplateAttributes = false; $scope.config.searchMatchedElementPaths = []; @@ -886,15 +835,29 @@ app.controller('AfExplorerFormCtrl', [ return acc; } + // reset all aggregates on change data type + function resetAggregate(attribute) { + Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { + if (!aggregate.isVisible(attribute)) { + attribute[aggregateName] = null + attribute[getAggregateValuesKey(aggregateName)] = [] + } + } + ) + } + $scope.updateMergedAttributeDataType = function(mergedAttribute) { const aggregateNames = getAggregateNames(); mergedAttribute.attributes.forEach(attribute => { attribute.data_type = mergedAttribute.data_type; aggregateNames.forEach(aggregateName => { + // TODO: check not necessary to copy to avoid arrays being linked attribute[aggregateName] = mergedAttribute[aggregateName]; }); - upsertOutputSelectedAttribute(attribute, attribute.checked); + if (attribute.checked) { + $scope.updateAttributeInSelection(attribute) + } }); }; @@ -968,25 +931,37 @@ app.controller('AfExplorerFormCtrl', [ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - function upsertOutputSelectedAttribute(attribute, isChecked) { - if (!attribute || !attribute.path) { + $scope.addAttributeToSelection = function(attribute) { + const index = $scope.config.outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); + if (index !== -1) { + console.warn("Cannot add attribute to selection because already present", attribute); return; } + attribute.checked = true; + $scope.config.outputSelectedAttributes.push(attribute); + console.log("Removed attribute from selection", attribute); + } - const outputSelectedAttributes = $scope.config.outputSelectedAttributes; - const index = outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); + $scope.removeAttributeFromSelection = function(attribute) { + const index = $scope.config.outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); + if (index === -1) { + console.warn("Cannot remove attribute from selection because not present", attribute); + return; + } + attribute.checked = false; + $scope.config.outputSelectedAttributes.splice(index, 1); + console.log("Removed attribute from selection", attribute); + } - if (isChecked) { - const attributeToStore = { ...attribute, checked: true }; - if (index === -1) { - outputSelectedAttributes.push(attributeToStore); - } else { - outputSelectedAttributes[index] = attributeToStore; - } - } else if (index !== -1) { - outputSelectedAttributes.splice(index, 1); + $scope.updateAttributeInSelection = function(attribute) { + const index = $scope.config.outputSelectedAttributes.findIndex(attr => attr.path === attribute.path); + if (index === -1) { + console.warn("Cannot update attribute in selection because not present", attribute); + return; } + $scope.config.outputSelectedAttributes[index] = attribute; } + }]); @@ -1011,7 +986,6 @@ app.component('treeNode', { ctrl.config.attribute_name = ""; ctrl.config.clickedNodes = []; ctrl.config.attributeList = []; - ctrl.config.selectedAttributes = []; ctrl.config.selectAllWithoutTemplateAttributes = false; ctrl.config.selectAllTemplateAttributes = false; ctrl.config.searchMatchedElementPaths = []; @@ -1054,7 +1028,6 @@ app.component('treeNode', { : []; ctrl.config.attributeList = []; - ctrl.config.selectedAttributes = []; ctrl.config.selectAllWithoutTemplateAttributes = false; ctrl.config.selectAllTemplateAttributes = false; From d19bcfc5bdf054d18a1615b624eaac60c4242155 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 29 Apr 2026 16:57:13 +0200 Subject: [PATCH 135/156] fix: default to empty array for recipe output --- custom-recipes/pi-system-af-tree/recipe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 29ea6f38..101ec8c6 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -66,7 +66,7 @@ def next_tree_item(tree_data): ] output_dataset.write_schema(schema) -selectedAttributes = config.get("outputSelectedAttributes") +selectedAttributes = config.get("outputSelectedAttributes", []) with output_dataset.get_writer() as writer: for item in selectedAttributes: if item.get("checked", True) is True: From 91d9a83f6be4728a5819b439ea1292bc51a46ba8 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 29 Apr 2026 16:57:31 +0200 Subject: [PATCH 136/156] feat: properly reset aggregates when changing data type --- js/pi-system_treecontroller.js | 15 +++++++++++++-- resource/pi-system_af-explorer.html | 8 ++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 3d461487..4269f0de 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -19,6 +19,7 @@ const aggregateDataTypeFields = Object.freeze({ label: 'Summary type', type: 'multiselect', dependsOn: ['data_type'], + defaultValue: [], isVisible: function(attribute) { return attribute.data_type === 'SummaryData'; }, @@ -840,17 +841,27 @@ app.controller('AfExplorerFormCtrl', [ Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if (!aggregate.isVisible(attribute)) { attribute[aggregateName] = null - attribute[getAggregateValuesKey(aggregateName)] = [] + return; } + attribute[aggregateName] = aggregate.defaultValue; } ) } $scope.updateMergedAttributeDataType = function(mergedAttribute) { + mergedAttribute.attributes.forEach(attribute => { + attribute.data_type = mergedAttribute.data_type; + resetAggregate(attribute); + if (attribute.checked) { + $scope.updateAttributeInSelection(attribute) + } + }); + } + + $scope.updateMergedAttributeAggregate = function(mergedAttribute) { const aggregateNames = getAggregateNames(); mergedAttribute.attributes.forEach(attribute => { - attribute.data_type = mergedAttribute.data_type; aggregateNames.forEach(aggregateName => { // TODO: check not necessary to copy to avoid arrays being linked attribute[aggregateName] = mergedAttribute[aggregateName]; diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index f6c9a132..4ce00af0 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -179,22 +179,22 @@ {{aggregate.label}}
              From efe34432fde5185c7c28b1c4b03eb5482331bb42 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Wed, 29 Apr 2026 17:50:06 +0200 Subject: [PATCH 137/156] feat: default value for aggregates --- js/pi-system_treecontroller.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 4269f0de..54583d35 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -737,11 +737,18 @@ app.controller('AfExplorerFormCtrl', [ // Merge frontend data and saved output with loaded attributes function enrichAttribute(attribute) { + // TODO: check this makes sense, since selectedOutput is persisted and so newly loaded attributes should not be found in it const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === attribute.path); attribute.checked = !!(selectedAttribute); attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; - getAggregateNames().forEach(aggregateName => { - attribute[aggregateName] = selectedAttribute?.[aggregateName]; + Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { + if ((selectedAttribute?.[aggregateName] === undefined || selectedAttribute?.[aggregateName] === null) && aggregate.isVisible(attribute)) { + attribute[aggregateName] = aggregate.defaultValue; + } else if (selectedAttribute?.[aggregateName] !== null && selectedAttribute?.[aggregateName] !== undefined) { + attribute[aggregateName] = selectedAttribute?.[aggregateName]; + } else { + attribute[aggregateName] = null; + } }); return attribute; } From 3d3ac9d0bab33dfdad671d40346d80052c2128c8 Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 30 Apr 2026 08:45:04 +0200 Subject: [PATCH 138/156] adding batch search of attribute template while lazy loading --- python-lib/osisoft_client.py | 21 +++++++++++++++++++++ resource/browse_af_tree.py | 13 ++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 225da5da..a5095764 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -3,6 +3,7 @@ import copy import json import simplejson +import re from datetime import datetime from requests_ntlm import HttpNtlmAuth from osisoft_constants import OSIsoftConstants @@ -963,6 +964,26 @@ def traverse_and_cache(self, ex_path_elements, ex_path_attributes, tree, trim_si item = self.extract_item_with_name(json_response, element_to_search) return get_item_details(item) + def get_attributes_templates_names(self, templates_urls): + batch_requests_parameters = [] + templates_names = [] + for template_url in templates_urls: + request_kwargs = { + "url": template_url, + "headers": self.get_requests_headers() + } + batch_requests_parameters.append(request_kwargs) + json_responses = self._batch_requests(batch_requests_parameters) + for json_response in json_responses: + response_content = json_response.get("Content", {}) + template_path = response_content.get("Path") + template_name_match = re.search(r'ElementTemplates\[([^\]]+)\]', template_path) + template_name = None + if template_name_match: + template_name = template_name_match.group(1) + templates_names.append(template_name) + return templates_names + def split_element_attribute(self, path_element): attribute = None path_elements = path_element.split("|") diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index c067001c..e66ab623 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -2,7 +2,6 @@ from safe_logger import SafeLogger from osisoft_plugin_common import get_credentials, build_select_choices, check_debug_mode from osisoft_plugin_common import get_item_details, Tree, recursive_tree_rebuild, PerformanceTimer -import dataiku import time logger = SafeLogger("PI System plugin", ["user", "password"]) @@ -284,16 +283,28 @@ def get_children_from_db(client, parent_node, database_name=None): children.append(child) if attributes_url: attributes = client.get_next_item_from_url(attributes_url) + templates_urls = [] for attribute in attributes: + # templates_urls are processed in batch for speed + templates_urls.append(extract_attribute_template_url(attribute)) child = get_item_details(attribute) # child["title"] = "🏷️{}".format(child.get("title")) child["type"] = "attribute" if child.get("has_children"): child["children"] = [] children.append(child) + templates_names = client.get_attributes_templates_names(templates_urls) + # post processing the batch response + for child, template_name in zip(children, templates_names): + if template_name: + child["template_name"] = template_name return {"choices": children} +def extract_attribute_template_url(attribute): + return attribute.get("Links", {}).get("Template") + + def get_template_hierarchy_from_db(client, parent_node, database_name=None): if isinstance(parent_node, dict): url = parent_node.get("url", database_name) From 92a398ff02f1be07dbcf1e75f47ab933acec89ec Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Thu, 30 Apr 2026 16:52:06 +0200 Subject: [PATCH 139/156] make batch request handle empty urls --- python-lib/osisoft_client.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index a5095764..3c44e089 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -307,7 +307,12 @@ def _batch_requests(self, batch_requests_parameters, method=None): batch_endpoint = self.endpoint.get_batch_endpoint() batch_body = {} index = 0 + empty_requests = [] for row_request_parameters in batch_requests_parameters: + if row_request_parameters.get("url") is None: + empty_requests.append(index) + index += 1 + continue batch_request = {} batch_request["Method"] = method batch_request["Resource"] = "{}".format(row_request_parameters.get("url")) @@ -320,6 +325,9 @@ def _batch_requests(self, batch_requests_parameters, method=None): response = self.post_value(url=batch_endpoint, data=batch_body) json_response = simplejson.loads(response.content) for index in range(0, len(batch_requests_parameters)): + if index in empty_requests: + yield {} + continue batch_section = json_response.get("{}".format(index), {}) yield batch_section @@ -976,7 +984,7 @@ def get_attributes_templates_names(self, templates_urls): json_responses = self._batch_requests(batch_requests_parameters) for json_response in json_responses: response_content = json_response.get("Content", {}) - template_path = response_content.get("Path") + template_path = response_content.get("Path", "") template_name_match = re.search(r'ElementTemplates\[([^\]]+)\]', template_path) template_name = None if template_name_match: From a5623301435e2a2505090e05d8eb20afa88f8a75 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Thu, 30 Apr 2026 18:15:52 +0200 Subject: [PATCH 140/156] feat: split attributes with/without templates --- js/pi-system_treecontroller.js | 37 +++++++++++++++++++++-------- resource/pi-system_af-explorer.html | 4 ++-- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 54583d35..a6b6d9b4 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -794,13 +794,15 @@ app.controller('AfExplorerFormCtrl', [ } function groupIdenticalAttributes(acc, attr) { - const key = attr.parent_template_name + "::" + attr.title; + // TODO: maybe switch to some kind of id + const key = attr.template_name + "::" + attr.title; + console.log("attribute", attr); if (!acc[key]) { acc[key] = { title: attr.title, description: attr.description, - templateName: attr.parent_template_name, + template_name: attr.template_name, checked: null, // Used to determine UI checkbox state allChecked: attr.checked, attributes: [], @@ -880,13 +882,26 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.getGroupedAttributesByTemplate = function() { - const groupedAttributes = Object.values($scope.config.attributeList.reduce(groupIdenticalAttributes, {})) + const splitAttributes = $scope.config.attributeList.reduce( + (accumulator, attribute) => { + // TODO: make the attribute have a template name even if no template + if (!attribute?.template_name) { + accumulator.attributesWithoutTemplate.push(attribute); + return accumulator; + } + accumulator.attributesWithTemplate.push(attribute); + return accumulator; + }, + { attributesWithoutTemplate: [], attributesWithTemplate: [] } + ); + + const groupedAttributes = Object.values(splitAttributes.attributesWithTemplate.reduce(groupIdenticalAttributes, {})) const groupedTemplates = Object.values(groupedAttributes.reduce( (acc, attr) => { - const key = attr.templateName; // TODO: check it's not template_name ever + const key = attr.template_name; if (!acc[key]) { acc[key] = { - templateName: attr.templateName, + template_name: attr.template_name, allChecked: attr.checked, checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state attributes: [], @@ -901,15 +916,15 @@ app.controller('AfExplorerFormCtrl', [ return acc; }, {} )); - const templateGroups = { + const attributesGroupedByTemplate = { allChecked: groupedTemplates.every(template => template.allChecked), checked: getCheckboxStatus(groupedTemplates.reduce((acc, arr) => acc.concat(arr.checkStates), [])), templates: groupedTemplates } - console.log("templateGroups", templateGroups); + console.log("attributesGroupedByTemplate", attributesGroupedByTemplate); return { - attributesWithoutTemplate: [], // TODO remove if does not exist, - templateGroups: templateGroups // TODO: see if can be avoided + attributesWithoutTemplate: splitAttributes.attributesWithoutTemplate, + attributesGroupedByTemplate: attributesGroupedByTemplate } } @@ -924,7 +939,9 @@ app.controller('AfExplorerFormCtrl', [ // TODO: try to move it to a callback of some kind (will work with a component) $scope.$watch('config.attributeList', function(newVal, oldVal) { - $scope.templateAggregatedAttributes = $scope.getGroupedAttributesByTemplate().templateGroups; + const formattedAttributes = $scope.getGroupedAttributesByTemplate(); + $scope.templateAggregatedAttributes = formattedAttributes.attributesGroupedByTemplate; + $scope.attributesWithoutTemplate = formattedAttributes.attributesWithoutTemplate; }, true); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 4ce00af0..4dac2339 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -147,13 +147,13 @@ No templated attributes - + - {{template.templateName}} + {{template.template_name}} From 02824c331e221281a2b61fd9e141f070a25cd633 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 14:11:43 +0200 Subject: [PATCH 141/156] refacto: extract attribute row into a separate component --- js/pi-system_treecontroller.js | 18 ++++++++++ resource/pi-system_af-explorer.html | 52 +++++------------------------ 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index a6b6d9b4..74a6192a 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -1160,6 +1160,24 @@ app.component('treeNode', { templateUrl: "/plugins/pi-system/resource/tree-node.html" }); +// TODO: see if cleaner architecture +app.directive('attributeTableRow', function() { + return { + restrict: 'A', + scope: { + mergedAttribute: '=', + aggregateDataTypeFields: '<', + onCheckAttribute: '&', + onUpdateDataType: '&', + onUpdateAggregate: '&', + }, + bindToController: true, + controllerAs: 'ctrl', + controller: function() {}, + templateUrl: "/plugins/pi-system/resource/attribute-table-row.html" + }; +}); + app.directive('indeterminate', function() { return { restrict: 'A', diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 4dac2339..8db08fc2 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -155,50 +155,14 @@ {{template.template_name}} - - - - - {{mergedAttribute.title}} - {{mergedAttribute.description}} - - - {{path}}
              -
              - - - - - -
              - {{aggregate.label}} - - - - -
              - + From a39e64e79f8eb34700e4a204a6a3b335eafa9478 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 14:13:26 +0200 Subject: [PATCH 142/156] refacto: follow up - add missing template --- resource/attribute-table-row.html | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 resource/attribute-table-row.html diff --git a/resource/attribute-table-row.html b/resource/attribute-table-row.html new file mode 100644 index 00000000..c480ff30 --- /dev/null +++ b/resource/attribute-table-row.html @@ -0,0 +1,40 @@ + + + +{{ctrl.mergedAttribute.title}} +{{ctrl.mergedAttribute.description}} + + + {{path}}
              +
              + + + + + +
              + {{aggregate.label}} + + + + +
              + From eaf7ab8961cac2d3fe3d9445ec034d29715eea3a Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 14:44:40 +0200 Subject: [PATCH 143/156] feat: enrich attributes in the attribute list with the name of their parent --- js/pi-system_treecontroller.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 74a6192a..c0c0b24d 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -736,10 +736,11 @@ app.controller('AfExplorerFormCtrl', [ } // Merge frontend data and saved output with loaded attributes - function enrichAttribute(attribute) { + function enrichAttribute(attribute, parentNode) { // TODO: check this makes sense, since selectedOutput is persisted and so newly loaded attributes should not be found in it const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === attribute.path); attribute.checked = !!(selectedAttribute); + attribute.parentElement = parentNode.title; attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if ((selectedAttribute?.[aggregateName] === undefined || selectedAttribute?.[aggregateName] === null) && aggregate.isVisible(attribute)) { @@ -767,7 +768,7 @@ app.controller('AfExplorerFormCtrl', [ } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { - $scope.config.attributeList.push(enrichAttribute(child)); + $scope.config.attributeList.push(enrichAttribute(child, node)); } } }); From f3b20bb0b260249ad17d8f18a2c91908716a8f5c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 15:51:46 +0200 Subject: [PATCH 144/156] feat: build and display lone attributes data structure - checkbox wiring unfinished --- js/pi-system_treecontroller.js | 156 ++++++++++++++++------------ resource/pi-system_af-explorer.html | 80 +++++++------- 2 files changed, 133 insertions(+), 103 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index c0c0b24d..d8835a33 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -619,8 +619,8 @@ app.controller('AfExplorerFormCtrl', [ }; $scope.toggleSelectAllTemplateAttributes = function() { - const shouldRemove = $scope.templateAggregatedAttributes.checked === CheckboxStatus.CHECKED; - $scope.templateAggregatedAttributes.templates.forEach((template) => { + const shouldRemove = $scope.groupedAttributes.attributesGroupedByTemplate.checked === CheckboxStatus.CHECKED; + $scope.groupedAttributes.attributesGroupedByTemplate.templates.forEach((template) => { template.attributes.forEach((aggregatedAttribute) => { aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { @@ -740,7 +740,7 @@ app.controller('AfExplorerFormCtrl', [ // TODO: check this makes sense, since selectedOutput is persisted and so newly loaded attributes should not be found in it const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === attribute.path); attribute.checked = !!(selectedAttribute); - attribute.parentElement = parentNode.title; + attribute.parent_element = parentNode.title; attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if ((selectedAttribute?.[aggregateName] === undefined || selectedAttribute?.[aggregateName] === null) && aggregate.isVisible(attribute)) { @@ -775,7 +775,7 @@ app.controller('AfExplorerFormCtrl', [ } $scope.getAttributesWithoutTemplate = function() { - return $scope.getGroupedAttributesByTemplate().attributesWithoutTemplate; + return $scope.buildGroupedAttributes().attributesWithoutTemplate; }; function getAggregateNames() { @@ -794,8 +794,45 @@ app.controller('AfExplorerFormCtrl', [ [...a].sort().every((v, i) => v === [...b].sort()[i]); } - function groupIdenticalAttributes(acc, attr) { - // TODO: maybe switch to some kind of id + + // reset all aggregates on change data type + function resetAggregate(attribute) { + Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { + if (!aggregate.isVisible(attribute)) { + attribute[aggregateName] = null + return; + } + attribute[aggregateName] = aggregate.defaultValue; + } + ) + } + + $scope.updateMergedAttributeDataType = function(mergedAttribute) { + mergedAttribute.attributes.forEach(attribute => { + attribute.data_type = mergedAttribute.data_type; + resetAggregate(attribute); + if (attribute.checked) { + $scope.updateAttributeInSelection(attribute) + } + }); + } + + $scope.updateMergedAttributeAggregate = function(mergedAttribute) { + const aggregateNames = getAggregateNames(); + + mergedAttribute.attributes.forEach(attribute => { + aggregateNames.forEach(aggregateName => { + // TODO: check not necessary to copy to avoid arrays being linked + attribute[aggregateName] = mergedAttribute[aggregateName]; + }); + if (attribute.checked) { + $scope.updateAttributeInSelection(attribute) + } + }); + }; + + function groupTemplateDuplicatedAttributes(acc, attr) { + // TODO: switch to id const key = attr.template_name + "::" + attr.title; console.log("attribute", attr); @@ -846,43 +883,45 @@ app.controller('AfExplorerFormCtrl', [ return acc; } - // reset all aggregates on change data type - function resetAggregate(attribute) { - Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { - if (!aggregate.isVisible(attribute)) { - attribute[aggregateName] = null - return; - } - attribute[aggregateName] = aggregate.defaultValue; + function groupAttributesByTemplate(acc, attr) { + const key = attr.template_name; + if (!acc[key]) { + acc[key] = { + template_name: attr.template_name, + allChecked: attr.checked, + checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state + attributes: [], + checkStates: [] } - ) - } + } - $scope.updateMergedAttributeDataType = function(mergedAttribute) { - mergedAttribute.attributes.forEach(attribute => { - attribute.data_type = mergedAttribute.data_type; - resetAggregate(attribute); - if (attribute.checked) { - $scope.updateAttributeInSelection(attribute) - } - }); + acc[key].checkStates.push(...attr.checkStates) + acc[key].checked = getCheckboxStatus(acc[key].checkStates); + acc[key].allChecked = acc[key].allChecked && attr.allChecked; + acc[key].attributes.push(attr); + return acc; } - $scope.updateMergedAttributeAggregate = function(mergedAttribute) { - const aggregateNames = getAggregateNames(); - - mergedAttribute.attributes.forEach(attribute => { - aggregateNames.forEach(aggregateName => { - // TODO: check not necessary to copy to avoid arrays being linked - attribute[aggregateName] = mergedAttribute[aggregateName]; - }); - if (attribute.checked) { - $scope.updateAttributeInSelection(attribute) + function groupAttributesByElement(acc, attr) { + const key = attr.parent_element; + if (!acc[key]) { + acc[key] = { + parent_element: attr.parent_element, + allChecked: attr.checked, + checked: null, + attributes: [], + checkStates: [] } - }); - }; + } + + acc[key].checkStates.push(attr.checked) + acc[key].checked = getCheckboxStatus(acc[key].checkStates); + acc[key].allChecked = acc[key].allChecked && attr.checked; + acc[key].attributes.push(attr); + return acc; + } - $scope.getGroupedAttributesByTemplate = function() { + $scope.buildGroupedAttributes = function() { const splitAttributes = $scope.config.attributeList.reduce( (accumulator, attribute) => { // TODO: make the attribute have a template name even if no template @@ -895,36 +934,23 @@ app.controller('AfExplorerFormCtrl', [ }, { attributesWithoutTemplate: [], attributesWithTemplate: [] } ); - - const groupedAttributes = Object.values(splitAttributes.attributesWithTemplate.reduce(groupIdenticalAttributes, {})) - const groupedTemplates = Object.values(groupedAttributes.reduce( - (acc, attr) => { - const key = attr.template_name; - if (!acc[key]) { - acc[key] = { - template_name: attr.template_name, - allChecked: attr.checked, - checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state - attributes: [], - checkStates: [] - } - } - - acc[key].checkStates.push(...attr.checkStates) - acc[key].checked = getCheckboxStatus(acc[key].checkStates); - acc[key].allChecked = acc[key].allChecked && attr.allChecked; - acc[key].attributes.push(attr); - return acc; - }, {} - )); + const groupedTemplateDuplicatedAttributes = Object.values(splitAttributes.attributesWithTemplate.reduce(groupTemplateDuplicatedAttributes, {})); + const groupedByTemplate = Object.values(groupedTemplateDuplicatedAttributes.reduce(groupAttributesByTemplate, {})); + const groupedByElement = Object.values(splitAttributes.attributesWithoutTemplate.reduce(groupAttributesByElement, {})); const attributesGroupedByTemplate = { - allChecked: groupedTemplates.every(template => template.allChecked), - checked: getCheckboxStatus(groupedTemplates.reduce((acc, arr) => acc.concat(arr.checkStates), [])), - templates: groupedTemplates + allChecked: groupedByTemplate.every(template => template.allChecked), + checked: getCheckboxStatus(groupedByTemplate.reduce((acc, arr) => acc.concat(arr.checkStates), [])), + templates: groupedByTemplate + } + const attributesGroupedByElement = { + allChecked: groupedByElement.every(element => element.allChecked), + checked: getCheckboxStatus(groupedByElement.reduce((acc, arr) => acc.concat(arr.checkStates), [])), + elements: groupedByElement } console.log("attributesGroupedByTemplate", attributesGroupedByTemplate); + console.log("attributesWithoutTemplate", attributesGroupedByElement); return { - attributesWithoutTemplate: splitAttributes.attributesWithoutTemplate, + attributesWithoutTemplate: attributesGroupedByElement, attributesGroupedByTemplate: attributesGroupedByTemplate } } @@ -940,9 +966,7 @@ app.controller('AfExplorerFormCtrl', [ // TODO: try to move it to a callback of some kind (will work with a component) $scope.$watch('config.attributeList', function(newVal, oldVal) { - const formattedAttributes = $scope.getGroupedAttributesByTemplate(); - $scope.templateAggregatedAttributes = formattedAttributes.attributesGroupedByTemplate; - $scope.attributesWithoutTemplate = formattedAttributes.attributesWithoutTemplate; + $scope.groupedAttributes = $scope.buildGroupedAttributes(); }, true); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 8db08fc2..75804af9 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -95,46 +95,52 @@
              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
              +
              Elements
              + + + + + + + + + + + + + + + + + + + + + + + + +
              TitleDescriptionPathData typeAggregates
              No templated attributes
              + + {{element.parent_element}}
              +
              Templates
              - + @@ -142,12 +148,12 @@ - + - + + ng-change="toggleSelectAllGroupedAttributes(groupedAttributes.attributesWithoutTemplate)" indeterminate="groupedAttributes.attributesWithoutTemplate.checked"> @@ -109,18 +109,18 @@ - + - + - + - + + ng-change="toggleSelectAllGroupedAttributes(groupedAttributes.attributesGroupedByTemplate)" indeterminate="groupedAttributes.attributesGroupedByTemplate.checked"> @@ -148,18 +148,18 @@ - + - + - + Date: Mon, 4 May 2026 16:57:19 +0200 Subject: [PATCH 146/156] feat + refacto: shape elements and templates tables data structure identical --- js/pi-system_treecontroller.js | 182 ++++++++++++++-------------- resource/pi-system_af-explorer.html | 12 +- 2 files changed, 95 insertions(+), 99 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 574b4f0c..b8bb3ee9 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -620,19 +620,17 @@ app.controller('AfExplorerFormCtrl', [ $scope.toggleSelectAllGroupedAttributes = function(groupedAttributes) { const shouldRemove = groupedAttributes.checked === CheckboxStatus.CHECKED; - // TODO: make it adapt to both grouping styles - groupedAttributes.attributesGroupedByTemplate.groups.forEach((group) => { - group.attributes.forEach((aggregatedAttribute) => { - aggregatedAttribute.attributes.forEach((underlyingAttribute) => { - if (shouldRemove) { - $scope.removeAttributeFromSelection(underlyingAttribute); - return; - } - $scope.addAttributeToSelection(underlyingAttribute); - }); + groupedAttributes.groups.forEach((group) => { + group.attributes.forEach((aggregatedAttribute) => { + aggregatedAttribute.attributes.forEach((underlyingAttribute) => { + if (shouldRemove) { + $scope.removeAttributeFromSelection(underlyingAttribute); + return; + } + $scope.addAttributeToSelection(underlyingAttribute); }); - } - ) + }); + }); }; $scope.toggleTemplateGroupAttributes = function(group) { @@ -832,96 +830,92 @@ app.controller('AfExplorerFormCtrl', [ }); }; - function groupTemplateDuplicatedAttributes(acc, attr) { - // TODO: switch to id - const key = attr.template_name + "::" + attr.title; - console.log("attribute", attr); - - if (!acc[key]) { - acc[key] = { - title: attr.title, - description: attr.description, - template_name: attr.template_name, - parent_elements: [], - checked: null, // Used to determine UI checkbox state - allChecked: attr.checked, - attributes: [], - checkStates: [], - paths: [], - data_type: attr.data_type, - data_types: [], - }; + // groupKey= template_name, parent_element + function groupDuplicatedAttributesAcrossGroup(groupKey) { + return (acc, attr) => { + // TODO: switch to id + const key = attr[groupKey] + "::" + attr.title; + console.log("attribute", attr); + + if (!acc[key]) { + acc[key] = { + title: attr.title, + description: attr.description, + group: attr[groupKey], + template_names: [], + parent_elements: [], + checked: null, // Used to determine UI checkbox state + allChecked: attr.checked, + attributes: [], + checkStates: [], + paths: [], + data_type: attr.data_type, + data_types: [], + }; + + getAggregateNames().forEach(aggregateName => { + acc[key][aggregateName] = attr[aggregateName]; + acc[key][getAggregateValuesKey(aggregateName)] = []; + }); + } + + acc[key].checkStates.push(attr.checked) + acc[key].template_names.push(attr.template_name) + acc[key].paths.push(attr.path) + acc[key].parent_elements.push(attr.parent_element); + acc[key].checked = getCheckboxStatus(acc[key].checkStates); // TODO maybe move out + acc[key].allChecked = acc[key].allChecked && attr.checked + acc[key].attributes.push(attr); + acc[key].data_types.push(attr.data_type); + + if (acc[key].data_type !== attr.data_type) { + acc[key].data_type = null; + } getAggregateNames().forEach(aggregateName => { - acc[key][aggregateName] = attr[aggregateName]; - acc[key][getAggregateValuesKey(aggregateName)] = []; + acc[key][getAggregateValuesKey(aggregateName)].push(attr[aggregateName]); + if ($scope.aggregateDataTypeFields.aggregates[aggregateName].type === 'multiselect') { + if (!stringArraysEqual(acc[key][aggregateName], attr[aggregateName])) { + acc[key][aggregateName] = []; + } + return; + } + if (acc[key][aggregateName] !== attr[aggregateName]) { + acc[key][aggregateName] = null; + } }); - } - - acc[key].checkStates.push(attr.checked) - acc[key].paths.push(attr.path) - acc[key].parent_elements.push(attr.parent_element); - acc[key].checked = getCheckboxStatus(acc[key].checkStates); // TODO maybe move out - acc[key].allChecked = acc[key].allChecked && attr.checked - acc[key].attributes.push(attr); - acc[key].data_types.push(attr.data_type); - if (acc[key].data_type !== attr.data_type) { - acc[key].data_type = null; + return acc } + } - getAggregateNames().forEach(aggregateName => { - acc[key][getAggregateValuesKey(aggregateName)].push(attr[aggregateName]); - if ($scope.aggregateDataTypeFields.aggregates[aggregateName].type === 'multiselect') { - if (!stringArraysEqual(acc[key][aggregateName], attr[aggregateName])) { - acc[key][aggregateName] = []; + // groupKey= template_names, parent_elements + function groupAttributes() { + return (acc, attr) => { + const key = attr.group; + if (!acc[key]) { + acc[key] = { + group_name: attr.group, + allChecked: attr.checked, + checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state + attributes: [], + checkStates: [] } - return; } - if (acc[key][aggregateName] !== attr[aggregateName]) { - acc[key][aggregateName] = null; - } - }); - return acc; - } - - function groupAttributesByTemplate(acc, attr) { - const key = attr.template_name; - if (!acc[key]) { - acc[key] = { - group_name: attr.template_name, - allChecked: attr.checked, - checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state - attributes: [], - checkStates: [] - } + acc[key].checkStates.push(...attr.checkStates) + acc[key].checked = getCheckboxStatus(acc[key].checkStates); + acc[key].allChecked = acc[key].allChecked && attr.allChecked; + acc[key].attributes.push(attr); + return acc; } - - acc[key].checkStates.push(...attr.checkStates) - acc[key].checked = getCheckboxStatus(acc[key].checkStates); - acc[key].allChecked = acc[key].allChecked && attr.allChecked; - acc[key].attributes.push(attr); - return acc; } - function groupAttributesByElement(acc, attr) { - const key = attr.parent_element; - if (!acc[key]) { - acc[key] = { - group_name: attr.parent_element, - allChecked: attr.checked, - checked: null, - attributes: [], - checkStates: [] - } - } - - acc[key].checkStates.push(attr.checked) - acc[key].checked = getCheckboxStatus(acc[key].checkStates); - acc[key].allChecked = acc[key].allChecked && attr.checked; - acc[key].attributes.push(attr); - return acc; + function buildAggregatedAttributes(attributes, groupKey) { + const deduplicatedAttributes = Object.values(attributes.reduce(groupDuplicatedAttributesAcrossGroup(groupKey), {})); + console.log("groupkey",groupKey) + console.log("dedupattributes",deduplicatedAttributes) + return Object.values(deduplicatedAttributes.reduce(groupAttributes(), {})); } $scope.buildGroupedAttributes = function() { @@ -937,9 +931,11 @@ app.controller('AfExplorerFormCtrl', [ }, { attributesWithoutTemplate: [], attributesWithTemplate: [] } ); - const groupedTemplateDuplicatedAttributes = Object.values(splitAttributes.attributesWithTemplate.reduce(groupTemplateDuplicatedAttributes, {})); - const groupedByTemplate = Object.values(groupedTemplateDuplicatedAttributes.reduce(groupAttributesByTemplate, {})); - const groupedByElement = Object.values(splitAttributes.attributesWithoutTemplate.reduce(groupAttributesByElement, {})); + console.log("split attributes", splitAttributes) + const groupedByTemplate = buildAggregatedAttributes(splitAttributes.attributesWithTemplate, 'template_name'); + const groupedByElement = buildAggregatedAttributes(splitAttributes.attributesWithoutTemplate, 'parent_element'); + console.log("groupedByTemplate", groupedByTemplate) + console.log("groupedByElement", groupedByElement) const attributesGroupedByTemplate = { allChecked: groupedByTemplate.every(template => template.allChecked), checked: getCheckboxStatus(groupedByTemplate.reduce((acc, arr) => acc.concat(arr.checkStates), [])), diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 339b9a1c..a399089a 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -114,11 +114,11 @@ - + @@ -127,9 +127,9 @@ attribute-table-row merged-attribute="mergedElement" aggregate-data-type-fields="aggregateDataTypeFields" - on-check-attribute="checkAttribute([mergedElement])" - on-update-data-type="updateMergedAttributeDataType([ mergedElement ])" - on-update-aggregate="updateMergedAttributeAggregate([ mergedElement ])"> + on-check-attribute="checkAttribute(mergedElement)" + on-update-data-type="updateMergedAttributeDataType(mergedElement)" + on-update-aggregate="updateMergedAttributeAggregate(mergedElement)">
              Title Description PathAggregates
              No templated attributes
              Date: Mon, 4 May 2026 16:11:30 +0200 Subject: [PATCH 145/156] refacto: starting to merge template and template less behavior --- js/pi-system_treecontroller.js | 19 +++++++++++-------- resource/pi-system_af-explorer.html | 20 ++++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index d8835a33..574b4f0c 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -618,10 +618,11 @@ app.controller('AfExplorerFormCtrl', [ setAttributesChecked($scope.getAttributesWithoutTemplate(), !!$scope.config.selectAllWithoutTemplateAttributes); }; - $scope.toggleSelectAllTemplateAttributes = function() { - const shouldRemove = $scope.groupedAttributes.attributesGroupedByTemplate.checked === CheckboxStatus.CHECKED; - $scope.groupedAttributes.attributesGroupedByTemplate.templates.forEach((template) => { - template.attributes.forEach((aggregatedAttribute) => { + $scope.toggleSelectAllGroupedAttributes = function(groupedAttributes) { + const shouldRemove = groupedAttributes.checked === CheckboxStatus.CHECKED; + // TODO: make it adapt to both grouping styles + groupedAttributes.attributesGroupedByTemplate.groups.forEach((group) => { + group.attributes.forEach((aggregatedAttribute) => { aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { $scope.removeAttributeFromSelection(underlyingAttribute); @@ -841,6 +842,7 @@ app.controller('AfExplorerFormCtrl', [ title: attr.title, description: attr.description, template_name: attr.template_name, + parent_elements: [], checked: null, // Used to determine UI checkbox state allChecked: attr.checked, attributes: [], @@ -858,6 +860,7 @@ app.controller('AfExplorerFormCtrl', [ acc[key].checkStates.push(attr.checked) acc[key].paths.push(attr.path) + acc[key].parent_elements.push(attr.parent_element); acc[key].checked = getCheckboxStatus(acc[key].checkStates); // TODO maybe move out acc[key].allChecked = acc[key].allChecked && attr.checked acc[key].attributes.push(attr); @@ -887,7 +890,7 @@ app.controller('AfExplorerFormCtrl', [ const key = attr.template_name; if (!acc[key]) { acc[key] = { - template_name: attr.template_name, + group_name: attr.template_name, allChecked: attr.checked, checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state attributes: [], @@ -906,7 +909,7 @@ app.controller('AfExplorerFormCtrl', [ const key = attr.parent_element; if (!acc[key]) { acc[key] = { - parent_element: attr.parent_element, + group_name: attr.parent_element, allChecked: attr.checked, checked: null, attributes: [], @@ -940,12 +943,12 @@ app.controller('AfExplorerFormCtrl', [ const attributesGroupedByTemplate = { allChecked: groupedByTemplate.every(template => template.allChecked), checked: getCheckboxStatus(groupedByTemplate.reduce((acc, arr) => acc.concat(arr.checkStates), [])), - templates: groupedByTemplate + groups: groupedByTemplate } const attributesGroupedByElement = { allChecked: groupedByElement.every(element => element.allChecked), checked: getCheckboxStatus(groupedByElement.reduce((acc, arr) => acc.concat(arr.checkStates), [])), - elements: groupedByElement + groups: groupedByElement } console.log("attributesGroupedByTemplate", attributesGroupedByTemplate); console.log("attributesWithoutTemplate", attributesGroupedByElement); diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 75804af9..339b9a1c 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -101,7 +101,7 @@
              Title Description PathAggregates
              No templated attributesNo attributes without template
              + ng-change="checkTemplate(element)" indeterminate="template.checked"> {{element.parent_element}}{{element.group_name}}
              Title Description PathAggregates
              No templated attributes
              {{template.template_name}}{{template.group_name}}
              No attributes without template
              + ng-change="checkTemplate(element)" indeterminate="element.checked"> {{element.group_name}}
              @@ -153,7 +153,7 @@ No templated attributes - + Date: Mon, 4 May 2026 17:06:41 +0200 Subject: [PATCH 147/156] refacto: code duplication in buildGroupedAttribugtes --- js/pi-system_treecontroller.js | 55 +++++++++++++++++----------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index b8bb3ee9..3ab2f681 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -913,47 +913,46 @@ app.controller('AfExplorerFormCtrl', [ function buildAggregatedAttributes(attributes, groupKey) { const deduplicatedAttributes = Object.values(attributes.reduce(groupDuplicatedAttributesAcrossGroup(groupKey), {})); - console.log("groupkey",groupKey) - console.log("dedupattributes",deduplicatedAttributes) return Object.values(deduplicatedAttributes.reduce(groupAttributes(), {})); } - $scope.buildGroupedAttributes = function() { - const splitAttributes = $scope.config.attributeList.reduce( + function splitAttributesByTemplatePresence(attributes) { + return attributes.reduce( (accumulator, attribute) => { // TODO: make the attribute have a template name even if no template - if (!attribute?.template_name) { - accumulator.attributesWithoutTemplate.push(attribute); - return accumulator; - } - accumulator.attributesWithTemplate.push(attribute); + const bucket = attribute?.template_name + ? 'attributesWithTemplate' + : 'attributesWithoutTemplate'; + accumulator[bucket].push(attribute); return accumulator; }, { attributesWithoutTemplate: [], attributesWithTemplate: [] } ); - console.log("split attributes", splitAttributes) - const groupedByTemplate = buildAggregatedAttributes(splitAttributes.attributesWithTemplate, 'template_name'); - const groupedByElement = buildAggregatedAttributes(splitAttributes.attributesWithoutTemplate, 'parent_element'); - console.log("groupedByTemplate", groupedByTemplate) - console.log("groupedByElement", groupedByElement) - const attributesGroupedByTemplate = { - allChecked: groupedByTemplate.every(template => template.allChecked), - checked: getCheckboxStatus(groupedByTemplate.reduce((acc, arr) => acc.concat(arr.checkStates), [])), - groups: groupedByTemplate - } - const attributesGroupedByElement = { - allChecked: groupedByElement.every(element => element.allChecked), - checked: getCheckboxStatus(groupedByElement.reduce((acc, arr) => acc.concat(arr.checkStates), [])), - groups: groupedByElement - } - console.log("attributesGroupedByTemplate", attributesGroupedByTemplate); - console.log("attributesWithoutTemplate", attributesGroupedByElement); + } + + function buildGroupedAttributesResult(attributes, groupKey) { + const groups = buildAggregatedAttributes(attributes, groupKey); return { - attributesWithoutTemplate: attributesGroupedByElement, - attributesGroupedByTemplate: attributesGroupedByTemplate + allChecked: groups.every(group => group.allChecked), + checked: getCheckboxStatus(groups.reduce((acc, group) => acc.concat(group.checkStates), [])), + groups: groups } } + $scope.buildGroupedAttributes = function() { + const splitAttributes = splitAttributesByTemplatePresence($scope.config.attributeList); + return { + attributesWithoutTemplate: buildGroupedAttributesResult( + splitAttributes.attributesWithoutTemplate, + 'parent_element' + ), + attributesGroupedByTemplate: buildGroupedAttributesResult( + splitAttributes.attributesWithTemplate, + 'template_name' + ) + }; + } + function getCheckboxStatus(checkboxStatuses) { if (checkboxStatuses.every(Boolean)) { return CheckboxStatus.CHECKED; From 46206f8546101d13015013d1babad7bafa7d4db3 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 17:17:50 +0200 Subject: [PATCH 148/156] refacto: make template and templateless tables common html --- js/pi-system_treecontroller.js | 12 ++++++ resource/pi-system_af-explorer.html | 63 ++++++----------------------- 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 3ab2f681..3519f93d 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -139,6 +139,18 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.selectAllTemplateAttributes = $scope.config.selectAllTemplateAttributes || false; // select all des attributs groupés par template $scope.aggregateDataTypeFields = aggregateDataTypeFields; + $scope.attributeGroupSections = [ + { + key: 'attributesWithoutTemplate', + title: 'Elements', + emptyMessage: 'No attributes without template' + }, + { + key: 'attributesGroupedByTemplate', + title: 'Templates', + emptyMessage: 'No templated attributes' + } + ]; $scope.onAdvancedToggle = function() { if (!$scope.config.show_advanced_parameters) { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index a399089a..09531f74 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -95,13 +95,15 @@
              -
              -
              Elements
              +
              +
              {{section.title}}
              - + @@ -109,60 +111,21 @@ - + - + - + - + - - -
              Title Description PathAggregates
              No attributes without template{{section.emptyMessage}}
              - + {{element.group_name}}{{group.group_name}}
              -
              -
              -
              Templates
              - - - - - - - - - - - - - - - - - - - - - - Date: Mon, 4 May 2026 17:30:37 +0200 Subject: [PATCH 149/156] cleanup --- js/pi-system_treecontroller.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 3519f93d..971fc450 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -842,12 +842,10 @@ app.controller('AfExplorerFormCtrl', [ }); }; - // groupKey= template_name, parent_element function groupDuplicatedAttributesAcrossGroup(groupKey) { return (acc, attr) => { // TODO: switch to id const key = attr[groupKey] + "::" + attr.title; - console.log("attribute", attr); if (!acc[key]) { acc[key] = { @@ -901,7 +899,6 @@ app.controller('AfExplorerFormCtrl', [ } } - // groupKey= template_names, parent_elements function groupAttributes() { return (acc, attr) => { const key = attr.group; From ba149642bd43268a3230de357ebfb3d235412f89 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 17:31:31 +0200 Subject: [PATCH 150/156] cleanup: reformat --- js/pi-system_treecontroller.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 971fc450..d38195ab 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -753,7 +753,7 @@ app.controller('AfExplorerFormCtrl', [ attribute.checked = !!(selectedAttribute); attribute.parent_element = parentNode.title; attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; - Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { + Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if ((selectedAttribute?.[aggregateName] === undefined || selectedAttribute?.[aggregateName] === null) && aggregate.isVisible(attribute)) { attribute[aggregateName] = aggregate.defaultValue; } else if (selectedAttribute?.[aggregateName] !== null && selectedAttribute?.[aggregateName] !== undefined) { @@ -808,7 +808,7 @@ app.controller('AfExplorerFormCtrl', [ // reset all aggregates on change data type function resetAggregate(attribute) { - Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { + Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if (!aggregate.isVisible(attribute)) { attribute[aggregateName] = null return; @@ -1205,7 +1205,8 @@ app.directive('attributeTableRow', function() { }, bindToController: true, controllerAs: 'ctrl', - controller: function() {}, + controller: function() { + }, templateUrl: "/plugins/pi-system/resource/attribute-table-row.html" }; }); From e0e1dcf5e8eb721bbf27f48b6937780b36e20867 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 17:47:57 +0200 Subject: [PATCH 151/156] fix: bad init of refactoed tables --- resource/pi-system_af-explorer.html | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 09531f74..844c7b66 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -96,14 +96,16 @@
              + ng-repeat="section in attributeGroupSections track by section.key">
              {{section.title}}
              TitleDescriptionPathData typeAggregates
              No templated attributes
              - - {{template.group_name}}
              - + + @@ -111,13 +113,14 @@ - + - - + + + + ng-repeat="mergedAttribute in group.attributes track by mergedAttribute.title" + attribute-table-row + merged-attribute="mergedAttribute" + aggregate-data-type-fields="aggregateDataTypeFields" + on-check-attribute="checkAttribute(mergedAttribute)" + on-update-data-type="updateMergedAttributeDataType(mergedAttribute)" + on-update-aggregate="updateMergedAttributeAggregate(mergedAttribute)">
              Title Description PathAggregates
              {{section.emptyMessage}}
              @@ -125,13 +128,13 @@ {{group.group_name}}
              From b6c346a31f90980a3fe2b7efda6ec9c00ea2401c Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Mon, 4 May 2026 17:48:23 +0200 Subject: [PATCH 152/156] fix: table checkbox is unchecked and disabled when table empty --- js/pi-system_treecontroller.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index d38195ab..aee05d83 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -942,7 +942,7 @@ app.controller('AfExplorerFormCtrl', [ function buildGroupedAttributesResult(attributes, groupKey) { const groups = buildAggregatedAttributes(attributes, groupKey); return { - allChecked: groups.every(group => group.allChecked), + allChecked: groups.length > 0 && groups.every(group => group.allChecked), checked: getCheckboxStatus(groups.reduce((acc, group) => acc.concat(group.checkStates), [])), groups: groups } @@ -963,6 +963,9 @@ app.controller('AfExplorerFormCtrl', [ } function getCheckboxStatus(checkboxStatuses) { + if (!checkboxStatuses.length) { + return CheckboxStatus.UNCHECKED; + } if (checkboxStatuses.every(Boolean)) { return CheckboxStatus.CHECKED; } else if (checkboxStatuses.some(Boolean)) { From 2a45da06251512914c600f390f48fb4593f58f3f Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 5 May 2026 14:55:36 +0200 Subject: [PATCH 153/156] cleanup: removing dead code --- js/pi-system_treecontroller.js | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index aee05d83..8370daf1 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -135,8 +135,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.lastSearchedElementName = $scope.config.lastSearchedElementName || ""; $scope.config.pendingTabContextReset = $scope.config.pendingTabContextReset || false; // indique le changement de tab template/element $scope.config.selectedTemplateNames = $scope.config.selectedTemplateNames || []; // la liste des templates sélectionnés (checkbox cochée) parmi ceux affichés - $scope.config.selectAllWithoutTemplateAttributes = $scope.config.selectAllWithoutTemplateAttributes || false; // select all des attributs standalone - $scope.config.selectAllTemplateAttributes = $scope.config.selectAllTemplateAttributes || false; // select all des attributs groupés par template $scope.aggregateDataTypeFields = aggregateDataTypeFields; $scope.attributeGroupSections = [ @@ -268,8 +266,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.lastSearchedElementName = ""; $scope.config.pendingTabContextReset = false; $scope.config.selectedTemplateNames = []; - $scope.config.selectAllWithoutTemplateAttributes = false; - $scope.config.selectAllTemplateAttributes = false; } $scope.resetDatasourceState = function() { // @@ -395,8 +391,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attribute_name = ""; $scope.config.clickedNodes = []; $scope.config.attributeList = []; - $scope.config.selectAllWithoutTemplateAttributes = false; - $scope.config.selectAllTemplateAttributes = false; $scope.config.searchMatchedElementPaths = []; $scope.config.selectedTemplateNames = []; if ($scope.config.activeTab === "element") { @@ -626,10 +620,6 @@ app.controller('AfExplorerFormCtrl', [ }); } - $scope.toggleSelectAllWithoutTemplateAttributes = function() { - setAttributesChecked($scope.getAttributesWithoutTemplate(), !!$scope.config.selectAllWithoutTemplateAttributes); - }; - $scope.toggleSelectAllGroupedAttributes = function(groupedAttributes) { const shouldRemove = groupedAttributes.checked === CheckboxStatus.CHECKED; groupedAttributes.groups.forEach((group) => { @@ -690,11 +680,13 @@ app.controller('AfExplorerFormCtrl', [ function removeNodeAttributes(node) { const attributePaths = getNodeAttributePaths(node); + console.log("attributePaths", attributePaths) + console.log("attributeList", $scope.config.attributeList) if (!attributePaths.length) { return; } - $scope.config.attributeList = ($scope.config.attributeList || []).filter( + $scope.config.attributeList = $scope.config.attributeList.filter( attr => !attributePaths.includes(attr.path) ); } @@ -708,17 +700,14 @@ app.controller('AfExplorerFormCtrl', [ } // TODO: cleanup + $scope.displayAttributes = function(node, remove = true) { - $scope.config.selectAllWithoutTemplateAttributes = false; - $scope.config.selectAllTemplateAttributes = false; if (!remove) { removeNodeAttributes(node); return; } - const shouldLoadChildrenFromDb = node.type === "element" && !hasAttributeChildren(node); - - if (shouldLoadChildrenFromDb) { + if (node.type === "element" && !hasAttributeChildren(node)) { $scope.config.template = "-- Any --"; $scope.getChildrenFromDB(node).then(newNode => { processNode(newNode); @@ -728,8 +717,6 @@ app.controller('AfExplorerFormCtrl', [ if (!selectedTemplateNames.length) { $scope.config.template = "-- Any --"; $scope.config.attributeList = []; - $scope.config.selectAllWithoutTemplateAttributes = false; - $scope.config.selectAllTemplateAttributes = false; $scope.config.searchMatchedElementPaths = []; return; } @@ -1056,8 +1043,6 @@ app.component('treeNode', { ctrl.config.attribute_name = ""; ctrl.config.clickedNodes = []; ctrl.config.attributeList = []; - ctrl.config.selectAllWithoutTemplateAttributes = false; - ctrl.config.selectAllTemplateAttributes = false; ctrl.config.searchMatchedElementPaths = []; if (ctrl.config.activeTab === "element") { @@ -1098,8 +1083,6 @@ app.component('treeNode', { : []; ctrl.config.attributeList = []; - ctrl.config.selectAllWithoutTemplateAttributes = false; - ctrl.config.selectAllTemplateAttributes = false; if (!clickedUrls.length) { return; From c2036d11d762d6cdfb992fec8ac591b0244a8460 Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 5 May 2026 14:59:58 +0200 Subject: [PATCH 154/156] cleanup: remove unused functions --- js/pi-system_treecontroller.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 8370daf1..b1ad398f 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -468,9 +468,6 @@ app.controller('AfExplorerFormCtrl', [ if (!isRestrictedAttributeSearch) { $scope.config.attributeList = []; - // Right-side display is reset: both table-level select-all checkboxes must be cleared. - $scope.config.selectAllWithoutTemplateAttributes = false; - $scope.config.selectAllTemplateAttributes = false; } $scope.config.searchMatchedElementPaths = []; // TODO: understand what this does @@ -635,14 +632,6 @@ app.controller('AfExplorerFormCtrl', [ }); }; - $scope.toggleTemplateGroupAttributes = function(group) { - if (!group || !Array.isArray(group?.mergedAttributes)) { - return; - } - const shouldCheck = !group.mergedAttributes.every(attribute => !!attribute.checked); - setAttributesChecked(group.mergedAttributes, shouldCheck); - }; - $scope.checkAttribute = function(attributeList) { const shouldRemove = attributeList.checked === CheckboxStatus.CHECKED; attributeList.attributes.forEach((attribute) => { @@ -772,10 +761,6 @@ app.controller('AfExplorerFormCtrl', [ }); } - $scope.getAttributesWithoutTemplate = function() { - return $scope.buildGroupedAttributes().attributesWithoutTemplate; - }; - function getAggregateNames() { return Object.keys($scope.aggregateDataTypeFields.aggregates); } From c06c84af28b432a543fd14597d581f5b664ca97a Mon Sep 17 00:00:00 2001 From: Mathilde Kaploun Date: Tue, 5 May 2026 16:36:25 +0200 Subject: [PATCH 155/156] fix + refacto: load children before stopping their display + rename displayAttributes --- js/pi-system_treecontroller.js | 75 +++++++++++++++-------------- resource/pi-system_af-explorer.html | 4 +- resource/tree-node.html | 2 +- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index b1ad398f..6ae49016 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -658,29 +658,7 @@ app.controller('AfExplorerFormCtrl', [ ) }; - function getNodeAttributePaths(node) { - if (!node || !Array.isArray(node.children)) { - return []; - } - return node.children - .filter(child => child.type === "attribute" && child.path) - .map(child => child.path); - } - - function removeNodeAttributes(node) { - const attributePaths = getNodeAttributePaths(node); - console.log("attributePaths", attributePaths) - console.log("attributeList", $scope.config.attributeList) - if (!attributePaths.length) { - return; - } - - $scope.config.attributeList = $scope.config.attributeList.filter( - attr => !attributePaths.includes(attr.path) - ); - } - - // TODO: double check the logic + // TODO: mark as loaded elements and replace this logic function hasAttributeChildren(node) { if (!Array.isArray(node.children) || node.children.length === 0) { return false @@ -688,14 +666,36 @@ app.controller('AfExplorerFormCtrl', [ return node.children.some(child => child.type === "attribute"); } - // TODO: cleanup - - $scope.displayAttributes = function(node, remove = true) { - if (!remove) { - removeNodeAttributes(node); - return; + function getChildren(node) { + if (hasAttributeChildren(node)) { + return Promise.resolve(node); } + return $scope.getChildrenFromDB(node); + } + + function stopDisplayingAttributes(node) { + // It is for now possible to stop displaying an element that was not loaded because of weak links + // patching it by loading the element before stopping to display it + // TODO: replace by weak link single loading logic + getChildren(node).then( node => { + const nodeAttributeChildrenPaths = node.children.filter(child => child.type === "attribute" && child.path) + .map(child => child.path) + if (!nodeAttributeChildrenPaths.length) { + return; + } + $scope.config.attributeList = $scope.config.attributeList.filter( + attr => !nodeAttributeChildrenPaths.includes(attr.path) + ); + }); + } + + $scope.toggleDisplayAttributes = function(node, add = true) { + if (!add) { + stopDisplayingAttributes(node); + return; + } + // TODO: refacto if (node.type === "element" && !hasAttributeChildren(node)) { $scope.config.template = "-- Any --"; $scope.getChildrenFromDB(node).then(newNode => { @@ -1011,7 +1011,7 @@ app.component('treeNode', { bindings: { node: '=', getChildrenFromDb: '<', - displayAttributes: '<', + toggleDisplayAttributes: '<', config: '<', }, @@ -1061,7 +1061,7 @@ app.component('treeNode', { return null; } - // TODO: understand why the logic is different from displayAttributes (merge them if possible) + // TODO: understand why the logic is different from toggleDisplayAttributes (merge them if possible) function rebuildAttributesFromClickedNodes() { const clickedUrls = Array.isArray(ctrl.config?.clickedNodes) ? ctrl.config.clickedNodes @@ -1078,7 +1078,7 @@ app.component('treeNode', { findNodeByUrl(ctrl.config.treeData, url) || findNodeByUrl(ctrl.config.templateTreeData, url); if (node) { - ctrl.displayAttributes(node, true); + ctrl.toggleDisplayAttributes(node); } }); } @@ -1126,8 +1126,9 @@ app.component('treeNode', { } const indexClickedNode = ctrl.config.clickedNodes.indexOf(node.url); + const nodeAlreadySelected = indexClickedNode > -1; // If the node is already clicked, remove it from clicked nodes - else add it - if (indexClickedNode > -1) { + if (nodeAlreadySelected) { ctrl.config.clickedNodes.splice(indexClickedNode, 1); } else { ctrl.config.clickedNodes.push(node.url); @@ -1136,23 +1137,23 @@ app.component('treeNode', { // TODO: split element/template logic if (node?.type === "template") { // Template clicks should always rebuild right-side content from the full template selection. - ctrl.displayAttributes(node, true); + ctrl.toggleDisplayAttributes(node); console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); return; } + // TODO: understand why this is mutually exclusive if (hasActiveAttributeSearch) { rebuildAttributesFromClickedNodes(); - } else if (indexClickedNode > -1) { - ctrl.displayAttributes(node, false); } else { - ctrl.displayAttributes(node); + ctrl.toggleDisplayAttributes(node, !nodeAlreadySelected); } console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); }; ctrl.isNodeClicked = function(node) { + // the click is entirely based on node.url return ctrl.config.clickedNodes.includes(node.url); }; diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index 844c7b66..b01977b0 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -185,14 +185,14 @@
              • -
              • -
              • diff --git a/resource/tree-node.html b/resource/tree-node.html index 5d9ebdef..4e2a198a 100644 --- a/resource/tree-node.html +++ b/resource/tree-node.html @@ -37,7 +37,7 @@ From 971872c5d71e934393fc96ca9af34c150401cc7a Mon Sep 17 00:00:00 2001 From: Alex Bourret Date: Tue, 5 May 2026 16:42:14 +0200 Subject: [PATCH 156/156] retrieving paths for get_children_from_db operations --- python-lib/osisoft_client.py | 4 ++-- resource/browse_af_tree.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 3c44e089..1f06024a 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -502,9 +502,9 @@ def get_item_from_url(self, url): ) return json_response - def get_next_item_from_url(self, url): + def get_next_item_from_url(self, url, params = None): headers = self.get_requests_headers() - params = {} + params = params or {} while url: json_response = self.get( url=url, diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index e66ab623..bea164a1 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -268,13 +268,13 @@ def get_children_from_db(client, parent_node, database_name=None): url = parent_node.get("url", database_name) else: url = parent_node - this_node = next(client.get_next_item_from_url(url)) + this_node = next(client.get_next_item_from_url(url, params={"associations": "Paths"})) links = this_node.get("Links", {}) attributes_url = links.get("Attributes") elements_url = links.get("Elements") children = [] if elements_url: - elements = client.get_next_item_from_url(elements_url) + elements = client.get_next_item_from_url(elements_url, params={"associations": "Paths"}) for element in elements: child = get_item_details(element) # child["title"] = "🧩{}".format(child.get("title")) @@ -282,7 +282,7 @@ def get_children_from_db(client, parent_node, database_name=None): child["children"] = [] children.append(child) if attributes_url: - attributes = client.get_next_item_from_url(attributes_url) + attributes = client.get_next_item_from_url(attributes_url, params={"associations": "Paths"}) templates_urls = [] for attribute in attributes: # templates_urls are processed in batch for speed