""" Thor Watcher — Settings Dialog v0.3.0 Provides a Tkinter settings dialog that doubles as a first-run wizard. Public API: show_dialog(config_path, wizard=False) -> bool Returns True if the user saved, False if they cancelled. """ import os import json import tkinter as tk from tkinter import ttk, filedialog, messagebox from socket import gethostname import series4_ingest as watcher # ── Defaults (mirror config.example.json) ──────────────────────────────────── DEFAULTS = { "thordata_path": r"C:\THORDATA", "scan_interval": 60, "api_url": "", "api_timeout": 5, "api_interval": 300, "source_id": "", "source_type": "series4_watcher", "local_timezone": "America/New_York", "enable_logging": True, "log_file": os.path.join( os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\", "ThorWatcher", "agent_logs", "thor_watcher.log" ), "log_retention_days": 30, "update_source": "gitea", "update_url": "", # SFM forwarder defaults — mirror series4_ingest.load_config "sfm_forward_enabled": False, "sfm_url": "", "sfm_forward_interval": 60, "sfm_quiescence_seconds": 5, "sfm_missing_report_grace_seconds": 60, "sfm_http_timeout": 60, "sfm_state_file": "", "sfm_max_forwards_per_pass": 500, "sfm_max_event_age_days": 365, } # ── Config I/O ──────────────────────────────────────────────────────────────── def _load_config(config_path): """Load existing config.json, merged with DEFAULTS for any missing key.""" values = dict(DEFAULTS) if not os.path.exists(config_path): return values try: with open(config_path, "r", encoding="utf-8") as f: raw = json.load(f) values.update(raw) except Exception: pass return values def _save_config(config_path, values): """Write values dict to config_path as JSON.""" config_dir = os.path.dirname(config_path) if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir) with open(config_path, "w", encoding="utf-8") as f: json.dump(values, f, indent=2) # ── Widget helpers ──────────────────────────────────────────────────────────── def _make_spinbox(parent, from_, to, width=8): try: sb = ttk.Spinbox(parent, from_=from_, to=to, width=width) except AttributeError: sb = tk.Spinbox(parent, from_=from_, to=to, width=width) return sb def _add_label_entry(frame, row, label_text, var, hint=None, readonly=False): tk.Label(frame, text=label_text, anchor="w").grid( row=row, column=0, sticky="w", padx=(8, 4), pady=4 ) state = "readonly" if readonly else "normal" entry = ttk.Entry(frame, textvariable=var, width=42, state=state) entry.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4) if hint and not var.get(): entry.config(foreground="grey") entry.insert(0, hint) def _on_focus_in(event, e=entry, h=hint, v=var): if e.get() == h: e.delete(0, tk.END) e.config(foreground="black") def _on_focus_out(event, e=entry, h=hint, v=var): if not e.get(): e.config(foreground="grey") e.insert(0, h) v.set("") entry.bind("", _on_focus_in) entry.bind("", _on_focus_out) return entry def _add_label_spinbox(frame, row, label_text, var, from_, to): tk.Label(frame, text=label_text, anchor="w").grid( row=row, column=0, sticky="w", padx=(8, 4), pady=4 ) sb = _make_spinbox(frame, from_=from_, to=to, width=8) sb.grid(row=row, column=1, sticky="w", padx=(0, 8), pady=4) sb.delete(0, tk.END) sb.insert(0, str(var.get())) def _on_change(*args): var.set(sb.get()) sb.config(command=_on_change) sb.bind("", _on_change) return sb def _add_label_check(frame, row, label_text, var): cb = ttk.Checkbutton(frame, text=label_text, variable=var) cb.grid(row=row, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=4) return cb def _add_label_browse_entry(frame, row, label_text, var, browse_fn): tk.Label(frame, text=label_text, anchor="w").grid( row=row, column=0, sticky="w", padx=(8, 4), pady=4 ) inner = tk.Frame(frame) inner.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4) inner.columnconfigure(0, weight=1) entry = ttk.Entry(inner, textvariable=var, width=36) entry.grid(row=0, column=0, sticky="ew") btn = ttk.Button(inner, text="Browse...", command=browse_fn, width=9) btn.grid(row=0, column=1, padx=(4, 0)) return entry # ── Main dialog class ───────────────────────────────────────────────────────── class SettingsDialog: def __init__(self, parent, config_path, wizard=False): self.config_path = config_path self.wizard = wizard self.saved = False self.root = parent kind = "Setup" if wizard else "Settings" title = "Thor Watcher v{} — {}".format(watcher.VERSION, kind) self.root.title(title) self.root.resizable(False, False) self.root.update_idletasks() self._values = _load_config(config_path) self._build_vars() self._build_ui() self.root.grab_set() self.root.protocol("WM_DELETE_WINDOW", self._on_cancel) # ── Variable setup ──────────────────────────────────────────────────────── def _build_vars(self): v = self._values # Connection raw_url = str(v.get("api_url", "")) _suffix = "/api/series4/heartbeat" if raw_url.endswith(_suffix): raw_url = raw_url[:-len(_suffix)] self.var_api_url = tk.StringVar(value=raw_url) self.var_api_interval = tk.StringVar(value=str(v.get("api_interval", 300))) self.var_source_id = tk.StringVar(value=str(v.get("source_id", ""))) self.var_source_type = tk.StringVar(value=str(v.get("source_type", "series4_watcher"))) # Paths self.var_thordata_path = tk.StringVar(value=str(v.get("thordata_path", r"C:\THORDATA"))) self.var_log_file = tk.StringVar(value=str(v.get("log_file", DEFAULTS["log_file"]))) # Scanning self.var_scan_interval = tk.StringVar(value=str(v.get("scan_interval", 60))) # Logging en = v.get("enable_logging", True) self.var_enable_logging = tk.BooleanVar(value=bool(en) if isinstance(en, bool) else str(en).lower() in ("true", "1", "yes")) self.var_log_retention_days = tk.StringVar(value=str(v.get("log_retention_days", 30))) # Updates src = str(v.get("update_source", "gitea")).lower() if src not in ("gitea", "url", "disabled"): src = "gitea" self.var_local_timezone = tk.StringVar(value=str(v.get("local_timezone", "America/New_York"))) self.var_update_source = tk.StringVar(value=src) self.var_update_url = tk.StringVar(value=str(v.get("update_url", ""))) # SFM Forwarder sfm_en = v.get("sfm_forward_enabled", False) self.var_sfm_enabled = tk.BooleanVar( value=bool(sfm_en) if isinstance(sfm_en, bool) else str(sfm_en).lower() in ("true", "1", "yes") ) self.var_sfm_url = tk.StringVar(value=str(v.get("sfm_url", ""))) self.var_sfm_forward_interval = tk.StringVar(value=str(v.get("sfm_forward_interval", 60))) self.var_sfm_quiescence = tk.StringVar(value=str(v.get("sfm_quiescence_seconds", 5))) self.var_sfm_grace = tk.StringVar(value=str(v.get("sfm_missing_report_grace_seconds", 60))) self.var_sfm_http_timeout = tk.StringVar(value=str(v.get("sfm_http_timeout", 60))) self.var_sfm_max_per_pass = tk.StringVar(value=str(v.get("sfm_max_forwards_per_pass", 500))) self.var_sfm_max_age_days = tk.StringVar(value=str(v.get("sfm_max_event_age_days", 365))) self.var_sfm_state_file = tk.StringVar(value=str(v.get("sfm_state_file", ""))) # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): outer = tk.Frame(self.root, padx=10, pady=8) outer.pack(fill="both", expand=True) if self.wizard: welcome = ( "Welcome to Thor Watcher!\n\n" "No configuration file was found. Please review the settings below\n" "and click \"Save & Start\" when you are ready." ) tk.Label( outer, text=welcome, justify="left", wraplength=460, fg="#1a5276", font=("TkDefaultFont", 9, "bold"), ).pack(fill="x", pady=(0, 8)) nb = ttk.Notebook(outer) nb.pack(fill="both", expand=True) self._build_tab_connection(nb) self._build_tab_paths(nb) self._build_tab_scanning(nb) self._build_tab_logging(nb) self._build_tab_forwarding(nb) self._build_tab_updates(nb) btn_frame = tk.Frame(outer) btn_frame.pack(fill="x", pady=(10, 0)) save_label = "Save & Start" if self.wizard else "Save" ttk.Button(btn_frame, text=save_label, command=self._on_save, width=14).pack(side="right", padx=(4, 0)) ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10).pack(side="right") def _tab_frame(self, nb, title): outer = tk.Frame(nb, padx=4, pady=4) nb.add(outer, text=title) outer.columnconfigure(1, weight=1) return outer def _build_tab_connection(self, nb): f = self._tab_frame(nb, "Connection") # URL row with Test button tk.Label(f, text="Terra-View URL", anchor="w").grid( row=0, column=0, sticky="w", padx=(8, 4), pady=4 ) url_frame = tk.Frame(f) url_frame.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4) url_frame.columnconfigure(0, weight=1) url_entry = ttk.Entry(url_frame, textvariable=self.var_api_url, width=32) url_entry.grid(row=0, column=0, sticky="ew") _hint = "http://192.168.x.x:8000" if not self.var_api_url.get(): url_entry.config(foreground="grey") url_entry.insert(0, _hint) def _on_focus_in(e): if url_entry.get() == _hint: url_entry.delete(0, tk.END) url_entry.config(foreground="black") def _on_focus_out(e): if not url_entry.get(): url_entry.config(foreground="grey") url_entry.insert(0, _hint) self.var_api_url.set("") url_entry.bind("", _on_focus_in) url_entry.bind("", _on_focus_out) self._test_btn = ttk.Button(url_frame, text="Test", width=6, command=self._test_connection) self._test_btn.grid(row=0, column=1, padx=(4, 0)) self._test_status = tk.Label(url_frame, text="", anchor="w", width=20) self._test_status.grid(row=0, column=2, padx=(6, 0)) _add_label_spinbox(f, 1, "API Interval (sec)", self.var_api_interval, 30, 3600) source_id_hint = "Defaults to hostname ({})".format(gethostname()) _add_label_entry(f, 2, "Source ID", self.var_source_id, hint=source_id_hint) _add_label_entry(f, 3, "Source Type", self.var_source_type, readonly=True) _add_label_entry(f, 4, "Local Timezone", self.var_local_timezone, hint="e.g. America/New_York, America/Chicago") tk.Label( f, text="Used to convert MLG file timestamps (local time) to UTC for terra-view.", justify="left", fg="#555555", wraplength=340, ).grid(row=5, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(0, 4)) def _test_connection(self): import urllib.request import urllib.error self._test_status.config(text="Testing...", foreground="grey") self._test_btn.config(state="disabled") self.root.update_idletasks() raw = self.var_api_url.get().strip() if not raw or raw == "http://192.168.x.x:8000": self._test_status.config(text="Enter a URL first", foreground="orange") self._test_btn.config(state="normal") return url = raw.rstrip("/") + "/health" try: with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp: if resp.status == 200: self._test_status.config(text="Connected!", foreground="green") else: self._test_status.config(text="HTTP {}".format(resp.status), foreground="orange") except urllib.error.URLError as e: reason = str(e.reason) if hasattr(e, "reason") else str(e) self._test_status.config(text="Failed: {}".format(reason[:30]), foreground="red") except Exception as e: self._test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red") finally: self._test_btn.config(state="normal") def _build_tab_paths(self, nb): f = self._tab_frame(nb, "Paths") def browse_thordata(): d = filedialog.askdirectory( title="Select THORDATA Folder", initialdir=self.var_thordata_path.get() or "C:\\", ) if d: self.var_thordata_path.set(d.replace("/", "\\")) _add_label_browse_entry(f, 0, "THORDATA Path", self.var_thordata_path, browse_thordata) def browse_log(): p = filedialog.asksaveasfilename( title="Select Log File", defaultextension=".log", filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")], initialfile=os.path.basename(self.var_log_file.get() or "thor_watcher.log"), initialdir=os.path.dirname(self.var_log_file.get() or "C:\\"), ) if p: self.var_log_file.set(p.replace("/", "\\")) _add_label_browse_entry(f, 1, "Log File", self.var_log_file, browse_log) def _build_tab_scanning(self, nb): f = self._tab_frame(nb, "Scanning") _add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600) def _build_tab_logging(self, nb): f = self._tab_frame(nb, "Logging") _add_label_check(f, 0, "Enable Logging", self.var_enable_logging) _add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365) def _build_tab_forwarding(self, nb): f = self._tab_frame(nb, "SFM Forward") # Row 0: enable checkbox _add_label_check(f, 0, "Enable SFM Forwarding", self.var_sfm_enabled) # Row 1: SFM URL + Test button tk.Label(f, text="SFM URL", anchor="w").grid( row=1, column=0, sticky="w", padx=(8, 4), pady=4 ) url_frame = tk.Frame(f) url_frame.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4) url_frame.columnconfigure(0, weight=1) sfm_entry = ttk.Entry(url_frame, textvariable=self.var_sfm_url, width=32) sfm_entry.grid(row=0, column=0, sticky="ew") _hint = "http://10.0.0.44:8200" if not self.var_sfm_url.get(): sfm_entry.config(foreground="grey") sfm_entry.insert(0, _hint) def _on_focus_in(e, ent=sfm_entry, h=_hint): if ent.get() == h: ent.delete(0, tk.END) ent.config(foreground="black") def _on_focus_out(e, ent=sfm_entry, h=_hint, v=self.var_sfm_url): if not ent.get(): ent.config(foreground="grey") ent.insert(0, h) v.set("") sfm_entry.bind("", _on_focus_in) sfm_entry.bind("", _on_focus_out) self._sfm_test_btn = ttk.Button(url_frame, text="Test", width=6, command=self._test_sfm_connection) self._sfm_test_btn.grid(row=0, column=1, padx=(4, 0)) self._sfm_test_status = tk.Label(url_frame, text="", anchor="w", width=20) self._sfm_test_status.grid(row=0, column=2, padx=(6, 0)) # Rows 2-7: timing/limits spinboxes _add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 30, 3600) _add_label_spinbox(f, 3, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60) _add_label_spinbox(f, 4, "Missing-Report Grace (sec)", self.var_sfm_grace, 0, 3600) _add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 300) _add_label_spinbox(f, 6, "Max Forwards Per Pass", self.var_sfm_max_per_pass, 1, 5000) _add_label_spinbox(f, 7, "Max Event Age (days)", self.var_sfm_max_age_days, 1, 3650) # Row 8: state file browse def browse_state(): p = filedialog.asksaveasfilename( title="Select SFM State File", defaultextension=".json", filetypes=[("JSON files", "*.json"), ("All files", "*.*")], initialfile=os.path.basename(self.var_sfm_state_file.get() or "thor_forwarded.json"), initialdir=os.path.dirname(self.var_sfm_state_file.get() or "C:\\"), ) if p: self.var_sfm_state_file.set(p.replace("/", "\\")) _add_label_browse_entry(f, 8, "State File", self.var_sfm_state_file, browse_state) # Row 9: help text help_text = ( "Forwards .IDFH (histogram) and .IDFW (waveform) event files plus their\n" "TXT/.txt sidecars to a seismo-relay SFM server.\n" "Idempotent: each file is tracked by sha256, so re-scans never re-POST.\n" "If the TXT sidecar appears AFTER the binary was forwarded alone, the\n" "next pass will re-forward so the relay can refresh the DB row with\n" "device-authoritative PPV/ZCFreq/peak values.\n" "State file blank → defaults to \\thor_forwarded.json." ) tk.Label( f, text=help_text, justify="left", fg="#555555", wraplength=420, ).grid(row=9, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)) def _test_sfm_connection(self): import urllib.request import urllib.error self._sfm_test_status.config(text="Testing...", foreground="grey") self._sfm_test_btn.config(state="disabled") self.root.update_idletasks() raw = self.var_sfm_url.get().strip() if not raw or raw == "http://10.0.0.44:8200": self._sfm_test_status.config(text="Enter a URL first", foreground="orange") self._sfm_test_btn.config(state="normal") return url = raw.rstrip("/") + "/health" try: with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp: if resp.status == 200: self._sfm_test_status.config(text="Connected!", foreground="green") else: self._sfm_test_status.config(text="HTTP {}".format(resp.status), foreground="orange") except urllib.error.URLError as e: reason = str(e.reason) if hasattr(e, "reason") else str(e) self._sfm_test_status.config(text="Failed: {}".format(reason[:30]), foreground="red") except Exception as e: self._sfm_test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red") finally: self._sfm_test_btn.config(state="normal") def _build_tab_updates(self, nb): f = self._tab_frame(nb, "Updates") tk.Label(f, text="Auto-Update Source", anchor="w").grid( row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 2) ) radio_frame = tk.Frame(f) radio_frame.grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 2)) ttk.Radiobutton( radio_frame, text="Gitea (default)", variable=self.var_update_source, value="gitea", command=self._on_update_source_change, ).grid(row=0, column=0, sticky="w", padx=(0, 12)) ttk.Radiobutton( radio_frame, text="Custom URL", variable=self.var_update_source, value="url", command=self._on_update_source_change, ).grid(row=0, column=1, sticky="w", padx=(0, 12)) ttk.Radiobutton( radio_frame, text="Disabled", variable=self.var_update_source, value="disabled", command=self._on_update_source_change, ).grid(row=0, column=2, sticky="w") tk.Label(f, text="Update Server URL", anchor="w").grid( row=1, column=0, sticky="w", padx=(8, 4), pady=4 ) self._update_url_entry = ttk.Entry(f, textvariable=self.var_update_url, width=42) self._update_url_entry.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4) tk.Label( f, text=( "Gitea: checks the Gitea release page automatically every 5 minutes.\n" "Custom URL: fetches version.txt and thor-watcher.exe from a web\n" "server — use when Gitea is not reachable (e.g. terra-view URL).\n" "Disabled: no automatic update checks. Remote push from terra-view\n" "still works when disabled." ), justify="left", fg="#555555", wraplength=380, ).grid(row=2, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(4, 8)) self._on_update_source_change() def _on_update_source_change(self): if self.var_update_source.get() == "url": self._update_url_entry.config(state="normal") else: self._update_url_entry.config(state="disabled") # ── Validation ──────────────────────────────────────────────────────────── def _get_int_var(self, var, name, min_val, max_val): raw = str(var.get()).strip() try: val = int(raw) except ValueError: messagebox.showerror("Validation Error", "{} must be an integer (got: {!r}).".format(name, raw)) return None if val < min_val or val > max_val: messagebox.showerror("Validation Error", "{} must be between {} and {} (got {}).".format(name, min_val, max_val, val)) return None return val # ── Save / Cancel ───────────────────────────────────────────────────────── def _on_save(self): checks = [ (self.var_api_interval, "API Interval", 30, 3600), (self.var_scan_interval, "Scan Interval", 10, 3600), (self.var_log_retention_days, "Log Retention Days", 1, 365), (self.var_sfm_forward_interval, "Forward Interval", 30, 3600), (self.var_sfm_quiescence, "Quiescence", 1, 60), (self.var_sfm_grace, "Missing-Report Grace", 0, 3600), (self.var_sfm_http_timeout, "HTTP Timeout", 5, 300), (self.var_sfm_max_per_pass, "Max Forwards Per Pass", 1, 5000), (self.var_sfm_max_age_days, "Max Event Age (days)", 1, 3650), ] int_values = {} for var, name, mn, mx in checks: result = self._get_int_var(var, name, mn, mx) if result is None: return int_values[name] = result source_id = self.var_source_id.get().strip() if source_id.startswith("Defaults to hostname"): source_id = "" api_url = self.var_api_url.get().strip() if api_url == "http://192.168.x.x:8000" or not api_url: api_url = "" else: api_url = api_url.rstrip("/") + "/api/series4/heartbeat" sfm_url = self.var_sfm_url.get().strip() if sfm_url == "http://10.0.0.44:8200": sfm_url = "" sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path values = { "thordata_path": self.var_thordata_path.get().strip(), "scan_interval": int_values["Scan Interval"], "api_url": api_url, "api_timeout": 5, "api_interval": int_values["API Interval"], "source_id": source_id, "source_type": self.var_source_type.get().strip() or "series4_watcher", "local_timezone": self.var_local_timezone.get().strip() or "America/New_York", "enable_logging": self.var_enable_logging.get(), "log_file": self.var_log_file.get().strip(), "log_retention_days": int_values["Log Retention Days"], "update_source": self.var_update_source.get().strip() or "gitea", "update_url": self.var_update_url.get().strip(), "sfm_forward_enabled": self.var_sfm_enabled.get(), "sfm_url": sfm_url, "sfm_forward_interval": int_values["Forward Interval"], "sfm_quiescence_seconds": int_values["Quiescence"], "sfm_missing_report_grace_seconds": int_values["Missing-Report Grace"], "sfm_http_timeout": int_values["HTTP Timeout"], "sfm_max_forwards_per_pass": int_values["Max Forwards Per Pass"], "sfm_max_event_age_days": int_values["Max Event Age (days)"], "sfm_state_file": self.var_sfm_state_file.get().strip(), } try: _save_config(self.config_path, values) except Exception as e: messagebox.showerror("Save Error", "Could not write config.json:\n{}".format(e)) return self.saved = True self.root.destroy() def _on_cancel(self): self.saved = False self.root.destroy() # ── Public API ──────────────────────────────────────────────────────────────── def show_dialog(config_path, wizard=False): """ Open the settings dialog. Parameters ---------- config_path : str Absolute path to config.json (read if exists, written on Save). wizard : bool If True, shows first-run welcome message and "Save & Start" button. Returns ------- bool True if the user saved, False if they cancelled. """ root = tk.Tk() root.withdraw() top = tk.Toplevel(root) top.deiconify() dlg = SettingsDialog(top, config_path, wizard=wizard) top.update_idletasks() w = top.winfo_reqwidth() h = top.winfo_reqheight() sw = top.winfo_screenwidth() sh = top.winfo_screenheight() top.geometry("{}x{}+{}+{}".format(w, h, (sw - w) // 2, (sh - h) // 2)) root.wait_window(top) root.destroy() return dlg.saved