DavMelchi commited on
Commit
0d0d4da
·
1 Parent(s): 98c8094

Add comprehensive profile management system for KPI health check panel with full state persistence including analysis parameters, filters, presets, and drill-down selections

Browse files
panel_app/kpi_health_check_panel.py CHANGED
@@ -32,6 +32,12 @@ from process_kpi.kpi_health_check.presets import (
32
  load_preset,
33
  save_preset,
34
  )
 
 
 
 
 
 
35
  from process_kpi.kpi_health_check.rules import infer_kpi_direction, infer_kpi_sla
36
 
37
  pn.extension("plotly", "tabulator")
@@ -56,6 +62,45 @@ current_top_anomalies_df: pd.DataFrame | None = None
56
  current_top_anomalies_raw: pd.DataFrame | None = None
57
  current_export_bytes: bytes | None = None
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  file_2g = pn.widgets.FileInput(name="2G KPI report", accept=".csv,.zip")
60
  file_3g = pn.widgets.FileInput(name="3G KPI report", accept=".csv,.zip")
61
  file_lte = pn.widgets.FileInput(name="LTE KPI report", accept=".csv,.zip")
@@ -89,6 +134,15 @@ preset_apply_button = pn.widgets.Button(name="Apply preset", button_type="primar
89
  preset_save_button = pn.widgets.Button(name="Save current rules", button_type="primary")
90
  preset_delete_button = pn.widgets.Button(name="Delete preset", button_type="danger")
91
 
 
 
 
 
 
 
 
 
 
92
  load_button = pn.widgets.Button(
93
  name="Load datasets & build rules", button_type="primary"
94
  )
@@ -170,6 +224,7 @@ def _filtered_daily(df: pd.DataFrame) -> pd.DataFrame:
170
 
171
 
172
  def _update_site_options() -> None:
 
173
  all_sites = []
174
  for df in current_daily_by_rat.values():
175
  if df is None or df.empty:
@@ -197,12 +252,20 @@ def _update_site_options() -> None:
197
  )
198
  opts[str(label)] = int(row["site_code"])
199
 
200
- site_select.options = opts
201
- if opts and site_select.value not in opts.values():
202
- site_select.value = next(iter(opts.values()))
 
 
 
 
203
 
204
 
205
  def _update_kpi_options() -> None:
 
 
 
 
206
  rat = rat_select.value
207
  df = current_daily_by_rat.get(rat)
208
  if df is None or df.empty:
@@ -216,12 +279,18 @@ def _update_kpi_options() -> None:
216
  if c not in {"site_code", "date_only", "Longitude", "Latitude", "City", "RAT"}
217
  ]
218
  kpis = sorted([str(c) for c in kpis])
219
- kpi_select.options = kpis
220
- if kpis and kpi_select.value not in kpis:
221
- kpi_select.value = kpis[0]
 
 
 
 
222
 
223
 
224
  def _update_site_view(event=None) -> None:
 
 
225
  code = site_select.value
226
  rat = rat_select.value
227
  kpi = kpi_select.value
@@ -271,9 +340,28 @@ def _update_site_view(event=None) -> None:
271
  )
272
  except Exception: # noqa: BLE001
273
  available_sites = set()
274
- if available_sites and int(code) not in available_sites:
275
- site_select.value = next(iter(sorted(available_sites)))
276
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  if not kpi or kpi not in d.columns:
279
  candidate_kpis = [
@@ -283,12 +371,14 @@ def _update_site_view(event=None) -> None:
283
  not in {"site_code", "date_only", "Longitude", "Latitude", "City", "RAT"}
284
  ]
285
  candidate_kpis = sorted([str(c) for c in candidate_kpis])
286
- if candidate_kpis:
287
- kpi_select.value = candidate_kpis[0]
288
- trend_plot_pane.object = None
289
- heatmap_plot_pane.object = None
290
- hist_plot_pane.object = None
291
- return
 
 
292
  s = d[d["site_code"] == int(code)].copy().sort_values("date_only")
293
  if s.empty:
294
  trend_plot_pane.object = None
@@ -615,6 +705,9 @@ def _compute_site_traffic_gb(daily_by_rat: dict[str, pd.DataFrame]) -> pd.DataFr
615
  def _refresh_filtered_results(event=None) -> None:
616
  global current_multirat_df, current_top_anomalies_df, current_export_bytes
617
 
 
 
 
618
  if current_multirat_raw is not None and not current_multirat_raw.empty:
619
  m = _apply_city_filter(current_multirat_raw)
620
  score_col = (
@@ -667,6 +760,204 @@ def _refresh_presets(event=None) -> None:
667
  preset_select.value = ""
668
 
669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  def _apply_preset(event=None) -> None:
671
  global current_export_bytes
672
  try:
@@ -746,6 +1037,8 @@ def _delete_selected_preset(event=None) -> None:
746
 
747
 
748
  def load_datasets(event=None) -> None:
 
 
749
  try:
750
  status_pane.alert_type = "primary"
751
  status_pane.object = "Loading datasets..."
@@ -841,9 +1134,6 @@ def load_datasets(event=None) -> None:
841
  current_rules_df = rules_df
842
  rules_table.value = rules_df
843
 
844
- _update_site_options()
845
- _update_kpi_options()
846
-
847
  status_pane.alert_type = "success"
848
  status_pane.object = (
849
  "Datasets loaded. Edit KPI rules if needed, then run health check."
@@ -853,6 +1143,15 @@ def load_datasets(event=None) -> None:
853
  status_pane.alert_type = "danger"
854
  status_pane.object = f"Error: {exc}"
855
 
 
 
 
 
 
 
 
 
 
856
 
857
  def run_health_check(event=None) -> None:
858
  try:
@@ -1010,11 +1309,30 @@ preset_apply_button.on_click(_apply_preset)
1010
  preset_save_button.on_click(_save_current_rules_as_preset)
1011
  preset_delete_button.on_click(_delete_selected_preset)
1012
 
 
 
 
 
 
1013
  _refresh_presets()
 
1014
 
1015
- rat_select.param.watch(lambda e: (_update_kpi_options(), _update_site_view()), "value")
1016
- site_select.param.watch(_update_site_view, "value")
1017
- kpi_select.param.watch(_update_site_view, "value")
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
 
1019
  min_criticality.param.watch(_refresh_filtered_results, "value")
1020
  min_anomaly_score.param.watch(_refresh_filtered_results, "value")
@@ -1050,6 +1368,12 @@ sidebar = pn.Column(
1050
  preset_name_input,
1051
  pn.Row(preset_save_button, preset_delete_button),
1052
  "---",
 
 
 
 
 
 
1053
  load_button,
1054
  run_button,
1055
  "---",
 
32
  load_preset,
33
  save_preset,
34
  )
35
+ from process_kpi.kpi_health_check.profiles import (
36
+ delete_profile,
37
+ list_profiles,
38
+ load_profile,
39
+ save_profile,
40
+ )
41
  from process_kpi.kpi_health_check.rules import infer_kpi_direction, infer_kpi_sla
42
 
43
  pn.extension("plotly", "tabulator")
 
62
  current_top_anomalies_raw: pd.DataFrame | None = None
63
  current_export_bytes: bytes | None = None
64
 
65
+ _applying_profile: bool = False
66
+ _loading_datasets: bool = False
67
+ _updating_drilldown: bool = False
68
+
69
+ _drilldown_update_pending: bool = False
70
+
71
+
72
+ def _set_widget_value(widget, value) -> None:
73
+ global _updating_drilldown
74
+ try:
75
+ if getattr(widget, "value", None) == value:
76
+ return
77
+ except Exception: # noqa: BLE001
78
+ pass
79
+ _updating_drilldown = True
80
+ try:
81
+ widget.value = value
82
+ finally:
83
+ _updating_drilldown = False
84
+
85
+
86
+ def _schedule_drilldown_update(fn) -> None:
87
+ global _drilldown_update_pending
88
+ if _drilldown_update_pending:
89
+ return
90
+ _drilldown_update_pending = True
91
+
92
+ def _wrapped() -> None:
93
+ global _drilldown_update_pending
94
+ _drilldown_update_pending = False
95
+ fn()
96
+
97
+ doc = pn.state.curdoc
98
+ if doc is not None:
99
+ doc.add_next_tick_callback(_wrapped)
100
+ else:
101
+ _wrapped()
102
+
103
+
104
  file_2g = pn.widgets.FileInput(name="2G KPI report", accept=".csv,.zip")
105
  file_3g = pn.widgets.FileInput(name="3G KPI report", accept=".csv,.zip")
106
  file_lte = pn.widgets.FileInput(name="LTE KPI report", accept=".csv,.zip")
 
134
  preset_save_button = pn.widgets.Button(name="Save current rules", button_type="primary")
135
  preset_delete_button = pn.widgets.Button(name="Delete preset", button_type="danger")
136
 
137
+ profile_select = pn.widgets.Select(name="Profile", options=[], value=None)
138
+ profile_name_input = pn.widgets.TextInput(name="Save profile as", value="")
139
+ profile_refresh_button = pn.widgets.Button(
140
+ name="Refresh profiles", button_type="default"
141
+ )
142
+ profile_apply_button = pn.widgets.Button(name="Apply profile", button_type="primary")
143
+ profile_save_button = pn.widgets.Button(name="Save profile", button_type="primary")
144
+ profile_delete_button = pn.widgets.Button(name="Delete profile", button_type="danger")
145
+
146
  load_button = pn.widgets.Button(
147
  name="Load datasets & build rules", button_type="primary"
148
  )
 
224
 
225
 
226
  def _update_site_options() -> None:
227
+ global _updating_drilldown
228
  all_sites = []
229
  for df in current_daily_by_rat.values():
230
  if df is None or df.empty:
 
252
  )
253
  opts[str(label)] = int(row["site_code"])
254
 
255
+ _updating_drilldown = True
256
+ try:
257
+ site_select.options = opts
258
+ if opts and site_select.value not in opts.values():
259
+ site_select.value = next(iter(opts.values()))
260
+ finally:
261
+ _updating_drilldown = False
262
 
263
 
264
  def _update_kpi_options() -> None:
265
+ if _applying_profile or _loading_datasets:
266
+ return
267
+
268
+ global _updating_drilldown
269
  rat = rat_select.value
270
  df = current_daily_by_rat.get(rat)
271
  if df is None or df.empty:
 
279
  if c not in {"site_code", "date_only", "Longitude", "Latitude", "City", "RAT"}
280
  ]
281
  kpis = sorted([str(c) for c in kpis])
282
+ _updating_drilldown = True
283
+ try:
284
+ kpi_select.options = kpis
285
+ if kpis and kpi_select.value not in kpis:
286
+ kpi_select.value = kpis[0]
287
+ finally:
288
+ _updating_drilldown = False
289
 
290
 
291
  def _update_site_view(event=None) -> None:
292
+ if _applying_profile or _loading_datasets or _updating_drilldown:
293
+ return
294
  code = site_select.value
295
  rat = rat_select.value
296
  kpi = kpi_select.value
 
340
  )
341
  except Exception: # noqa: BLE001
342
  available_sites = set()
343
+ if available_sites:
344
+ try:
345
+ code_int = int(code)
346
+ except Exception: # noqa: BLE001
347
+ code_int = None
348
+ if code_int is None or code_int not in available_sites:
349
+ new_code = next(iter(sorted(available_sites)))
350
+ _set_widget_value(site_select, new_code)
351
+ code = new_code
352
+
353
+ status_df = (
354
+ current_status_df
355
+ if isinstance(current_status_df, pd.DataFrame)
356
+ else pd.DataFrame()
357
+ )
358
+ if status_df is None or status_df.empty:
359
+ site_df = pd.DataFrame()
360
+ else:
361
+ site_df = status_df[
362
+ (status_df["site_code"] == int(code)) & (status_df["RAT"] == rat)
363
+ ].copy()
364
+ site_kpi_table.value = site_df
365
 
366
  if not kpi or kpi not in d.columns:
367
  candidate_kpis = [
 
371
  not in {"site_code", "date_only", "Longitude", "Latitude", "City", "RAT"}
372
  ]
373
  candidate_kpis = sorted([str(c) for c in candidate_kpis])
374
+ if not candidate_kpis:
375
+ trend_plot_pane.object = None
376
+ heatmap_plot_pane.object = None
377
+ hist_plot_pane.object = None
378
+ return
379
+ new_kpi = candidate_kpis[0]
380
+ _set_widget_value(kpi_select, new_kpi)
381
+ kpi = new_kpi
382
  s = d[d["site_code"] == int(code)].copy().sort_values("date_only")
383
  if s.empty:
384
  trend_plot_pane.object = None
 
705
  def _refresh_filtered_results(event=None) -> None:
706
  global current_multirat_df, current_top_anomalies_df, current_export_bytes
707
 
708
+ if _applying_profile or _loading_datasets:
709
+ return
710
+
711
  if current_multirat_raw is not None and not current_multirat_raw.empty:
712
  m = _apply_city_filter(current_multirat_raw)
713
  score_col = (
 
760
  preset_select.value = ""
761
 
762
 
763
+ def _refresh_profiles(event=None) -> None:
764
+ names = list_profiles()
765
+ profile_select.options = [""] + names
766
+ if profile_select.value not in profile_select.options:
767
+ profile_select.value = ""
768
+
769
+
770
+ def _current_profile_config() -> dict:
771
+ cfg: dict = {}
772
+
773
+ cfg["analysis_range"] = (
774
+ [
775
+ (
776
+ str(analysis_range.value[0])
777
+ if analysis_range.value and analysis_range.value[0]
778
+ else None
779
+ ),
780
+ (
781
+ str(analysis_range.value[1])
782
+ if analysis_range.value and analysis_range.value[1]
783
+ else None
784
+ ),
785
+ ]
786
+ if analysis_range.value
787
+ else [None, None]
788
+ )
789
+
790
+ cfg["baseline_days"] = int(baseline_days.value)
791
+ cfg["recent_days"] = int(recent_days.value)
792
+ cfg["rel_threshold_pct"] = float(rel_threshold_pct.value)
793
+ cfg["min_consecutive_days"] = int(min_consecutive_days.value)
794
+
795
+ cfg["min_criticality"] = int(min_criticality.value)
796
+ cfg["min_anomaly_score"] = int(min_anomaly_score.value)
797
+ cfg["city_filter"] = str(city_filter.value or "")
798
+ cfg["top_rat_filter"] = list(top_rat_filter.value) if top_rat_filter.value else []
799
+ cfg["top_status_filter"] = (
800
+ list(top_status_filter.value) if top_status_filter.value else []
801
+ )
802
+
803
+ cfg["preset_selected"] = str(preset_select.value or "")
804
+
805
+ cfg["drilldown"] = {
806
+ "site_code": int(site_select.value) if site_select.value is not None else None,
807
+ "rat": str(rat_select.value or ""),
808
+ "kpi": str(kpi_select.value or ""),
809
+ }
810
+ return cfg
811
+
812
+
813
+ def _apply_profile_config(cfg: dict) -> None:
814
+ global _applying_profile
815
+ if cfg is None or not isinstance(cfg, dict):
816
+ return
817
+
818
+ _applying_profile = True
819
+
820
+ try:
821
+ try:
822
+ ar = cfg.get("analysis_range", [None, None])
823
+ if isinstance(ar, (list, tuple)) and len(ar) == 2 and ar[0] and ar[1]:
824
+ analysis_range.value = (
825
+ pd.to_datetime(ar[0]).date(),
826
+ pd.to_datetime(ar[1]).date(),
827
+ )
828
+ else:
829
+ analysis_range.value = None
830
+ except Exception: # noqa: BLE001
831
+ pass
832
+
833
+ for w, key, cast in [
834
+ (baseline_days, "baseline_days", int),
835
+ (recent_days, "recent_days", int),
836
+ (rel_threshold_pct, "rel_threshold_pct", float),
837
+ (min_consecutive_days, "min_consecutive_days", int),
838
+ (min_criticality, "min_criticality", int),
839
+ (min_anomaly_score, "min_anomaly_score", int),
840
+ ]:
841
+ try:
842
+ if key in cfg and cfg[key] is not None:
843
+ w.value = cast(cfg[key])
844
+ except Exception: # noqa: BLE001
845
+ pass
846
+
847
+ try:
848
+ city_filter.value = str(cfg.get("city_filter", "") or "")
849
+ except Exception: # noqa: BLE001
850
+ pass
851
+
852
+ try:
853
+ tr = cfg.get("top_rat_filter", [])
854
+ if isinstance(tr, list):
855
+ top_rat_filter.value = [x for x in tr if x in top_rat_filter.options]
856
+ except Exception: # noqa: BLE001
857
+ pass
858
+
859
+ try:
860
+ ts = cfg.get("top_status_filter", [])
861
+ if isinstance(ts, list):
862
+ top_status_filter.value = [
863
+ x for x in ts if x in top_status_filter.options
864
+ ]
865
+ except Exception: # noqa: BLE001
866
+ pass
867
+
868
+ try:
869
+ preset_name = str(cfg.get("preset_selected", "") or "").strip()
870
+ if preset_name:
871
+ _refresh_presets()
872
+ if preset_name in preset_select.options:
873
+ preset_select.value = preset_name
874
+ try:
875
+ _apply_preset()
876
+ except Exception: # noqa: BLE001
877
+ pass
878
+ except Exception: # noqa: BLE001
879
+ pass
880
+
881
+ drill = (
882
+ cfg.get("drilldown", {})
883
+ if isinstance(cfg.get("drilldown", {}), dict)
884
+ else {}
885
+ )
886
+ try:
887
+ rat = str(drill.get("rat", "") or "")
888
+ if rat and rat in list(rat_select.options):
889
+ rat_select.value = rat
890
+ except Exception: # noqa: BLE001
891
+ pass
892
+ try:
893
+ sc = drill.get("site_code", None)
894
+ if sc is not None:
895
+ site_select.value = int(sc)
896
+ except Exception: # noqa: BLE001
897
+ pass
898
+ try:
899
+ kpi = str(drill.get("kpi", "") or "")
900
+ if kpi:
901
+ kpi_select.value = kpi
902
+ except Exception: # noqa: BLE001
903
+ pass
904
+ finally:
905
+ _applying_profile = False
906
+
907
+ _refresh_filtered_results()
908
+ _update_kpi_options()
909
+ _update_site_view()
910
+
911
+
912
+ def _apply_profile(event=None) -> None:
913
+ try:
914
+ if not profile_select.value:
915
+ return
916
+ cfg = load_profile(str(profile_select.value))
917
+ _apply_profile_config(cfg)
918
+ status_pane.alert_type = "success"
919
+ status_pane.object = f"Profile applied: {profile_select.value}"
920
+ except Exception as exc: # noqa: BLE001
921
+ status_pane.alert_type = "danger"
922
+ status_pane.object = f"Error applying profile: {exc}"
923
+
924
+
925
+ def _save_profile(event=None) -> None:
926
+ try:
927
+ name = (profile_name_input.value or "").strip()
928
+ if not name:
929
+ name = str(profile_select.value or "").strip()
930
+ if not name:
931
+ raise ValueError("Please provide a profile name")
932
+
933
+ cfg = _current_profile_config()
934
+ save_profile(name, cfg)
935
+
936
+ profile_name_input.value = ""
937
+ _refresh_profiles()
938
+ profile_select.value = name
939
+
940
+ status_pane.alert_type = "success"
941
+ status_pane.object = f"Profile saved: {name}"
942
+ except Exception as exc: # noqa: BLE001
943
+ status_pane.alert_type = "danger"
944
+ status_pane.object = f"Error saving profile: {exc}"
945
+
946
+
947
+ def _delete_profile(event=None) -> None:
948
+ try:
949
+ name = str(profile_select.value or "").strip()
950
+ if not name:
951
+ return
952
+ delete_profile(name)
953
+ _refresh_profiles()
954
+ status_pane.alert_type = "success"
955
+ status_pane.object = f"Profile deleted: {name}"
956
+ except Exception as exc: # noqa: BLE001
957
+ status_pane.alert_type = "danger"
958
+ status_pane.object = f"Error deleting profile: {exc}"
959
+
960
+
961
  def _apply_preset(event=None) -> None:
962
  global current_export_bytes
963
  try:
 
1037
 
1038
 
1039
  def load_datasets(event=None) -> None:
1040
+ global _loading_datasets
1041
+ _loading_datasets = True
1042
  try:
1043
  status_pane.alert_type = "primary"
1044
  status_pane.object = "Loading datasets..."
 
1134
  current_rules_df = rules_df
1135
  rules_table.value = rules_df
1136
 
 
 
 
1137
  status_pane.alert_type = "success"
1138
  status_pane.object = (
1139
  "Datasets loaded. Edit KPI rules if needed, then run health check."
 
1143
  status_pane.alert_type = "danger"
1144
  status_pane.object = f"Error: {exc}"
1145
 
1146
+ finally:
1147
+ _loading_datasets = False
1148
+ try:
1149
+ _update_site_options()
1150
+ _update_kpi_options()
1151
+ _update_site_view()
1152
+ except Exception: # noqa: BLE001
1153
+ pass
1154
+
1155
 
1156
  def run_health_check(event=None) -> None:
1157
  try:
 
1309
  preset_save_button.on_click(_save_current_rules_as_preset)
1310
  preset_delete_button.on_click(_delete_selected_preset)
1311
 
1312
+ profile_refresh_button.on_click(_refresh_profiles)
1313
+ profile_apply_button.on_click(_apply_profile)
1314
+ profile_save_button.on_click(_save_profile)
1315
+ profile_delete_button.on_click(_delete_profile)
1316
+
1317
  _refresh_presets()
1318
+ _refresh_profiles()
1319
 
1320
+
1321
+ def _on_rat_change(event=None) -> None:
1322
+ if _applying_profile or _loading_datasets or _updating_drilldown:
1323
+ return
1324
+ _schedule_drilldown_update(lambda: (_update_kpi_options(), _update_site_view()))
1325
+
1326
+
1327
+ def _on_drilldown_change(event=None) -> None:
1328
+ if _applying_profile or _loading_datasets or _updating_drilldown:
1329
+ return
1330
+ _schedule_drilldown_update(_update_site_view)
1331
+
1332
+
1333
+ rat_select.param.watch(_on_rat_change, "value")
1334
+ site_select.param.watch(_on_drilldown_change, "value")
1335
+ kpi_select.param.watch(_on_drilldown_change, "value")
1336
 
1337
  min_criticality.param.watch(_refresh_filtered_results, "value")
1338
  min_anomaly_score.param.watch(_refresh_filtered_results, "value")
 
1368
  preset_name_input,
1369
  pn.Row(preset_save_button, preset_delete_button),
1370
  "---",
1371
+ pn.pane.Markdown("### Profiles"),
1372
+ profile_select,
1373
+ pn.Row(profile_refresh_button, profile_apply_button),
1374
+ profile_name_input,
1375
+ pn.Row(profile_save_button, profile_delete_button),
1376
+ "---",
1377
  load_button,
1378
  run_button,
1379
  "---",
process_kpi/kpi_health_check/profiles.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+
5
+
6
+ def profiles_dir() -> str:
7
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+ return os.path.join(root, "data", "kpi_health_check_profiles")
9
+
10
+
11
+ def _safe_name(name: str) -> str:
12
+ s = (name or "").strip()
13
+ s = s.replace("..", "")
14
+ s = s.replace("/", "_").replace("\\", "_")
15
+ s = "_".join([p for p in s.split() if p])
16
+ return s
17
+
18
+
19
+ def list_profiles() -> list[str]:
20
+ d = profiles_dir()
21
+ if not os.path.isdir(d):
22
+ return []
23
+ out: list[str] = []
24
+ for fn in os.listdir(d):
25
+ if fn.lower().endswith(".json"):
26
+ out.append(os.path.splitext(fn)[0])
27
+ return sorted(set(out))
28
+
29
+
30
+ def load_profile(name: str) -> dict:
31
+ d = profiles_dir()
32
+ safe = _safe_name(name)
33
+ path = os.path.join(d, f"{safe}.json")
34
+ with open(path, "r", encoding="utf-8") as f:
35
+ obj = json.load(f)
36
+ if isinstance(obj, dict) and "config" in obj and isinstance(obj["config"], dict):
37
+ return obj["config"]
38
+ if isinstance(obj, dict):
39
+ return obj
40
+ return {}
41
+
42
+
43
+ def save_profile(name: str, config: dict) -> str:
44
+ safe = _safe_name(name)
45
+ if not safe:
46
+ raise ValueError("Profile name is empty")
47
+ if config is None or not isinstance(config, dict) or not config:
48
+ raise ValueError("Profile config is empty")
49
+
50
+ d = profiles_dir()
51
+ os.makedirs(d, exist_ok=True)
52
+ path = os.path.join(d, f"{safe}.json")
53
+
54
+ obj = {
55
+ "name": safe,
56
+ "saved_at": datetime.utcnow().isoformat() + "Z",
57
+ "config": config,
58
+ }
59
+
60
+ with open(path, "w", encoding="utf-8") as f:
61
+ json.dump(obj, f, ensure_ascii=False, indent=2)
62
+
63
+ return path
64
+
65
+
66
+ def delete_profile(name: str) -> None:
67
+ d = profiles_dir()
68
+ safe = _safe_name(name)
69
+ path = os.path.join(d, f"{safe}.json")
70
+ if os.path.isfile(path):
71
+ os.remove(path)