# target_simulator/gui/logger_panel.py """ A small Toplevel UI to inspect and change logger levels at runtime. """ import tkinter as tk from tkinter import ttk, messagebox import logging from typing import List from target_simulator.utils.config_manager import ConfigManager import os import json LEVELS = [ ("NOTSET", logging.NOTSET), ("DEBUG", logging.DEBUG), ("INFO", logging.INFO), ("WARNING", logging.WARNING), ("ERROR", logging.ERROR), ("CRITICAL", logging.CRITICAL), ] class LoggerPanel(tk.Toplevel): """Toplevel window that allows setting logger levels at runtime.""" def __init__(self, master=None): super().__init__(master) self.title("Logger Levels") self.geometry("520x420") self.transient(master) self.grab_set() self.logger_names = [] # type: List[str] # Config manager for persistence try: self._cfg = ConfigManager() except Exception: self._cfg = None self._create_widgets() self._populate_logger_list() # Load persisted selections and levels try: self._load_persisted() except Exception: pass def _create_widgets(self): top = ttk.Frame(self) top.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) # Left: filter + list of logger names left = ttk.Frame(top) left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) ttk.Label(left, text="Filter:").pack(anchor=tk.W) self.filter_var = tk.StringVar(value="") self.filter_entry = ttk.Entry(left, textvariable=self.filter_var) self.filter_entry.pack(fill=tk.X, padx=(0, 6), pady=(2, 4)) # Call _on_filter_change on text changes try: # Python 3.6+ trace_add preferred self.filter_var.trace_add("write", lambda *args: self._on_filter_change()) except Exception: # Fallback for older trace try: self.filter_var.trace("w", lambda *args: self._on_filter_change()) except Exception: pass ttk.Label(left, text="Available loggers:").pack(anchor=tk.W) self.logger_listbox = tk.Listbox(left, exportselection=False) self.logger_listbox.pack(fill=tk.BOTH, expand=True, padx=(0, 6), pady=(4, 0)) self.logger_listbox.bind("<>", self._on_select_logger) # Right: controls right = ttk.Frame(top) right.pack(side=tk.RIGHT, fill=tk.Y) ttk.Label(right, text="Selected logger:").pack(anchor=tk.W) self.selected_name_var = tk.StringVar(value="") ttk.Label(right, textvariable=self.selected_name_var, foreground="blue").pack(anchor=tk.W, pady=(0, 6)) ttk.Label(right, text="Level:").pack(anchor=tk.W) self.level_var = tk.StringVar(value="INFO") level_names = [n for n, v in LEVELS] self.level_combo = ttk.Combobox(right, values=level_names, textvariable=self.level_var, state="readonly", width=12) self.level_combo.pack(anchor=tk.W, pady=(0, 6)) ttk.Button(right, text="Apply", command=self._apply_level).pack(fill=tk.X, pady=(6, 4)) ttk.Button(right, text="Reset to NOTSET", command=self._reset_level).pack(fill=tk.X) ttk.Button(right, text="Restore defaults", command=self._restore_defaults).pack(fill=tk.X, pady=(6, 0)) ttk.Separator(self).pack(fill=tk.X, pady=6) bottom = ttk.Frame(self) bottom.pack(fill=tk.X, padx=8, pady=6) ttk.Label(bottom, text="Add / open logger by name:").pack(anchor=tk.W) self.new_logger_var = tk.StringVar() entry = ttk.Entry(bottom, textvariable=self.new_logger_var) entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) ttk.Button(bottom, text="Open", command=self._open_named_logger).pack(side=tk.LEFT) ttk.Button(self, text="Refresh", command=self._populate_logger_list).pack(side=tk.RIGHT, padx=8, pady=(0, 8)) ttk.Button(self, text="Export", command=self._export_prefs).pack(side=tk.RIGHT, padx=(0,8), pady=(0,8)) ttk.Button(self, text="Close", command=self._on_close).pack(side=tk.RIGHT, pady=(0, 8)) def _gather_logger_names(self) -> List[str]: # Gather logger names from the logging manager plus some defaults manager = logging.root.manager names = list(getattr(manager, 'loggerDict', {}).keys()) # Add a few commonly useful module names if missing defaults = [ 'target_simulator', 'target_simulator.analysis.simulation_state_hub', 'target_simulator.gui.sfp_debug_window', 'target_simulator.gui.ppi_display', 'target_simulator.gui.payload_router', 'target_simulator.core.sfp_transport', ] for d in defaults: if d not in names: names.append(d) names = sorted(set(names)) return names def _refresh_list(self): """Refresh listbox contents applying current filter against _all_logger_names.""" try: filt = (self.filter_var.get() or "").strip().lower() except Exception: filt = "" # Build filtered list if hasattr(self, "_all_logger_names") and self._all_logger_names: source = self._all_logger_names else: source = self.logger_names if filt: filtered = [n for n in source if filt in n.lower()] else: filtered = list(source) # Remember current selection name to try to preserve it try: sel_idx = self.logger_listbox.curselection() sel_name = self.logger_names[sel_idx[0]] if sel_idx else None except Exception: sel_name = None # Update listbox self.logger_listbox.delete(0, tk.END) self.logger_names = sorted(filtered) for n in self.logger_names: self.logger_listbox.insert(tk.END, n) # Restore selection if still present if sel_name and sel_name in self.logger_names: idx = self.logger_names.index(sel_name) self.logger_listbox.select_set(idx) self.logger_listbox.see(idx) self._on_select_logger() def _on_filter_change(self, *args): """Callback for filter text changes; refresh the list.""" try: self._refresh_list() except Exception: pass def _populate_logger_list(self): self.logger_listbox.delete(0, tk.END) # Gather and cache the full set of logger names, then refresh list self._all_logger_names = self._gather_logger_names() # Apply filter and populate listbox self._refresh_list() # If user previously selected a logger, select it try: if self._cfg: gen = self._cfg.get_general_settings() lp = gen.get("logger_panel", {}) if isinstance(gen, dict) else {} last = lp.get("last_selected") if last and last in self.logger_names: idx = self.logger_names.index(last) self.logger_listbox.select_set(idx) self.logger_listbox.see(idx) self._on_select_logger() except Exception: pass def _on_select_logger(self, event=None): sel = self.logger_listbox.curselection() if not sel: return idx = sel[0] name = self.logger_names[idx] self.selected_name_var.set(name) lg = logging.getLogger(name) lvl = lg.getEffectiveLevel() # Translate to name lvl_name = logging.getLevelName(lvl) self.level_var.set(lvl_name) def _apply_level(self): name = self.selected_name_var.get() if not name: messagebox.showwarning("No logger selected", "Select a logger from the list first.") return lvl_name = self.level_var.get() lvl = next((v for n, v in LEVELS if n == lvl_name), logging.INFO) logging.getLogger(name).setLevel(lvl) # Persist this selection into dedicated logger_prefs.json (single source of truth) try: cfg_path = getattr(self._cfg, 'filepath', None) if self._cfg else None prefs_path = os.path.join(os.path.dirname(cfg_path), 'logger_prefs.json') if cfg_path else os.path.join(os.getcwd(), 'logger_prefs.json') # Read existing prefs try: with open(prefs_path, 'r', encoding='utf-8') as f: prefs = json.load(f) if f else {} if not isinstance(prefs, dict): prefs = {} except Exception: prefs = {} saved = prefs.get('saved_levels', {}) if isinstance(prefs.get('saved_levels', {}), dict) else {} saved[name] = lvl_name prefs['saved_levels'] = saved prefs['last_selected'] = name with open(prefs_path, 'w', encoding='utf-8') as f: json.dump(prefs, f, indent=4) except Exception: pass # Post a status message to the application log instead of a modal dialog try: # Show a transient status in the main UI if available parent = getattr(self, 'master', None) if parent and hasattr(parent, 'show_status_message'): parent.show_status_message(f"Logger '{name}' set to {lvl_name} (saved)") else: logging.getLogger(__name__).info("Logger '%s' set to %s (persisted)", name, lvl_name) except Exception: try: messagebox.showinfo("Logger level set", f"Logger '{name}' set to {lvl_name}.") except Exception: pass def _reset_level(self): name = self.selected_name_var.get() if not name: messagebox.showwarning("No logger selected", "Select a logger from the list first.") return logging.getLogger(name).setLevel(logging.NOTSET) self.level_var.set('NOTSET') # Remove persisted level for this logger from logger_prefs.json try: cfg_path = getattr(self._cfg, 'filepath', None) if self._cfg else None prefs_path = os.path.join(os.path.dirname(cfg_path), 'logger_prefs.json') if cfg_path else os.path.join(os.getcwd(), 'logger_prefs.json') try: with open(prefs_path, 'r', encoding='utf-8') as f: prefs = json.load(f) if f else {} if not isinstance(prefs, dict): prefs = {} except Exception: prefs = {} saved = prefs.get('saved_levels', {}) if isinstance(prefs.get('saved_levels', {}), dict) else {} if name in saved: del saved[name] prefs['saved_levels'] = saved # if last_selected pointed to this logger, remove it if prefs.get('last_selected') == name: prefs.pop('last_selected', None) with open(prefs_path, 'w', encoding='utf-8') as f: json.dump(prefs, f, indent=4) except Exception: pass try: parent = getattr(self, 'master', None) if parent and hasattr(parent, 'show_status_message'): parent.show_status_message(f"Logger '{name}' reset to NOTSET (saved)") else: logging.getLogger(__name__).info("Logger '%s' reset to NOTSET (persisted)", name) except Exception: try: messagebox.showinfo("Logger reset", f"Logger '{name}' reset to NOTSET.") except Exception: pass def _open_named_logger(self): name = self.new_logger_var.get().strip() if not name: return # If exists in list, select it try: idx = self._all_logger_names.index(name) except ValueError: # Add to list # Add to cached master list and refresh if not hasattr(self, "_all_logger_names"): self._all_logger_names = [] if name not in self._all_logger_names: self._all_logger_names.append(name) self._all_logger_names.sort() self._populate_logger_list() idx = self.logger_names.index(name) self.logger_listbox.select_clear(0, tk.END) self.logger_listbox.select_set(idx) self.logger_listbox.see(idx) self._on_select_logger() # Persist last selected into logger_prefs.json try: cfg_path = getattr(self._cfg, 'filepath', None) if self._cfg else None prefs_path = os.path.join(os.path.dirname(cfg_path), 'logger_prefs.json') if cfg_path else os.path.join(os.getcwd(), 'logger_prefs.json') try: with open(prefs_path, 'r', encoding='utf-8') as f: prefs = json.load(f) if f else {} if not isinstance(prefs, dict): prefs = {} except Exception: prefs = {} prefs['last_selected'] = name with open(prefs_path, 'w', encoding='utf-8') as f: json.dump(prefs, f, indent=4) except Exception: pass try: parent = getattr(self, 'master', None) if parent and hasattr(parent, 'show_status_message'): parent.show_status_message(f"Selected logger: {name}") except Exception: pass def _restore_defaults(self): """Clear all saved logger preferences and reset those loggers to NOTSET.""" if not self._cfg: try: messagebox.showinfo("Restore defaults", "Could not access settings to restore defaults.") except Exception: pass return try: gen = self._cfg.get_general_settings() lp = gen.get("logger_panel", {}) if isinstance(gen, dict) else {} saved = lp.get("saved_levels", {}) if isinstance(lp, dict) else {} # Reset each saved logger to NOTSET for name in list(saved.keys()): try: logging.getLogger(name).setLevel(logging.NOTSET) except Exception: pass # Remove saved_levels # Clear dedicated prefs file instead of touching settings.json cfg_path = getattr(self._cfg, 'filepath', None) if self._cfg else None prefs_path = os.path.join(os.path.dirname(cfg_path), 'logger_prefs.json') if cfg_path else os.path.join(os.getcwd(), 'logger_prefs.json') try: with open(prefs_path, 'w', encoding='utf-8') as f: json.dump({"saved_levels": {}}, f, indent=4) except Exception: # fallback: attempt to remove the file try: if os.path.exists(prefs_path): os.remove(prefs_path) except Exception: pass logging.getLogger(__name__).info("LoggerPanel: restored defaults and cleared saved preferences") # Refresh the list and selection self._populate_logger_list() except Exception: try: messagebox.showinfo("Restore defaults", "Failed to restore defaults.") except Exception: pass def _export_prefs(self): """Export saved logger preferences to a separate JSON file for inspection.""" if not self._cfg: try: messagebox.showinfo("Export prefs", "Settings manager not available; cannot export.") except Exception: pass return try: gen = self._cfg.get_general_settings() lp = gen.get("logger_panel", {}) if isinstance(gen, dict) else {} saved = lp.get("saved_levels", {}) if isinstance(lp, dict) else {} # Determine export path next to settings.json cfg_path = getattr(self._cfg, 'filepath', None) export_dir = os.path.dirname(cfg_path) if cfg_path else os.getcwd() export_path = os.path.join(export_dir, "logger_prefs.json") with open(export_path, 'w', encoding='utf-8') as f: json.dump({"saved_levels": saved}, f, indent=4) parent = getattr(self, 'master', None) if parent and hasattr(parent, 'show_status_message'): parent.show_status_message(f"Exported logger prefs to {export_path}") else: logging.getLogger(__name__).info("Exported logger prefs to %s", export_path) except Exception: try: messagebox.showinfo("Export prefs", "Failed to export logger preferences.") except Exception: pass def _on_close(self): try: self.grab_release() except Exception: pass self.destroy() def _load_persisted(self): """Load persisted logger panel settings and apply saved levels.""" # Prefer reading the dedicated prefs file cfg_path = getattr(self._cfg, 'filepath', None) if self._cfg else None prefs_path = os.path.join(os.path.dirname(cfg_path), 'logger_prefs.json') if cfg_path else os.path.join(os.getcwd(), 'logger_prefs.json') saved = {} try: if os.path.exists(prefs_path): with open(prefs_path, 'r', encoding='utf-8') as f: jp = json.load(f) if isinstance(jp, dict): saved = jp.get('saved_levels', {}) or {} last = jp.get('last_selected') if last: # store last_selected in prefs for later UI use pass else: # Fallback to settings.json if self._cfg: gen = self._cfg.get_general_settings() lp = gen.get("logger_panel", {}) if isinstance(gen, dict) else {} saved = lp.get("saved_levels", {}) if isinstance(lp, dict) else {} except Exception: saved = {} # Apply saved levels to loggers for name, lvl_name in (saved or {}).items(): try: lvl = logging.getLevelName(lvl_name) if isinstance(lvl, int): logging.getLogger(name).setLevel(lvl) except Exception: pass