feat: best-effort mirror (dual-send) of heartbeats + events
Optional second destination so each heartbeat and event is posted to a mirror server (the office NAS) alongside the primary — dual-write to de-risk the migration. Default off (blank mirror URLs); existing installs unchanged. - event_forwarder: mirror_reachable() fast-fail probe + mirror_forward_pass() (reliable event mirror with its OWN state file, total exception isolation, never raises into the primary path). - series4_ingest: mirror_api_url/mirror_sfm_url/mirror_sfm_state_file config; best-effort heartbeat mirror; isolated event-mirror pass after the primary. - settings dialog: new 'Mirror' tab. - 9 new tests incl. the isolation invariant (down mirror = fast no-op that never touches primary state). 42 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,11 @@ DEFAULTS = {
|
||||
"sfm_state_file": "",
|
||||
"sfm_max_forwards_per_pass": 500,
|
||||
"sfm_max_event_age_days": 365,
|
||||
|
||||
# Mirror (dual-send) — best-effort second destination, default OFF.
|
||||
"mirror_api_url": "",
|
||||
"mirror_sfm_url": "",
|
||||
"mirror_sfm_state_file": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +225,14 @@ class SettingsDialog:
|
||||
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", "")))
|
||||
|
||||
# Mirror (dual-send) — best-effort second destination
|
||||
raw_mirror = str(v.get("mirror_api_url", ""))
|
||||
if raw_mirror.endswith(_suffix):
|
||||
raw_mirror = raw_mirror[:-len(_suffix)]
|
||||
self.var_mirror_api_url = tk.StringVar(value=raw_mirror)
|
||||
self.var_mirror_sfm_url = tk.StringVar(value=str(v.get("mirror_sfm_url", "")))
|
||||
self.var_mirror_sfm_state_file = tk.StringVar(value=str(v.get("mirror_sfm_state_file", "")))
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -245,6 +258,7 @@ class SettingsDialog:
|
||||
self._build_tab_scanning(nb)
|
||||
self._build_tab_logging(nb)
|
||||
self._build_tab_forwarding(nb)
|
||||
self._build_tab_mirror(nb)
|
||||
self._build_tab_updates(nb)
|
||||
|
||||
btn_frame = tk.Frame(outer)
|
||||
@@ -484,6 +498,49 @@ class SettingsDialog:
|
||||
finally:
|
||||
self._sfm_test_btn.config(state="normal")
|
||||
|
||||
def _build_tab_mirror(self, nb):
|
||||
f = self._tab_frame(nb, "Mirror")
|
||||
|
||||
intro = (
|
||||
"Optional dual-send: post each heartbeat and event to a SECOND\n"
|
||||
"(\"mirror\") server in addition to the primary. Best-effort — the\n"
|
||||
"mirror can never delay or fail the primary. Leave blank to disable."
|
||||
)
|
||||
tk.Label(f, text=intro, justify="left", fg="#1a5276", wraplength=420).grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(6, 8)
|
||||
)
|
||||
|
||||
_add_label_entry(f, 1, "Mirror Terra-View URL", self.var_mirror_api_url)
|
||||
_add_label_entry(f, 2, "Mirror SFM URL", self.var_mirror_sfm_url)
|
||||
|
||||
def browse_mirror_state():
|
||||
p = filedialog.asksaveasfilename(
|
||||
title="Select Mirror State File",
|
||||
defaultextension=".json",
|
||||
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
||||
initialfile=os.path.basename(
|
||||
self.var_mirror_sfm_state_file.get() or "thor_forwarded_mirror.json"),
|
||||
initialdir=os.path.dirname(self.var_mirror_sfm_state_file.get() or "C:\\"),
|
||||
)
|
||||
if p:
|
||||
self.var_mirror_sfm_state_file.set(p.replace("/", "\\"))
|
||||
|
||||
_add_label_browse_entry(f, 3, "Mirror State File", self.var_mirror_sfm_state_file,
|
||||
browse_mirror_state)
|
||||
|
||||
help_text = (
|
||||
"Mirror Terra-View URL → base like http://10.0.0.x:8001 (heartbeats).\n"
|
||||
"Mirror SFM URL → base like http://10.0.0.x:8200 (events).\n"
|
||||
"The event mirror keeps its OWN state file, so nothing is lost while\n"
|
||||
"the mirror is down — pending events are delivered once it returns.\n"
|
||||
"A quick reachability check skips the mirror cleanly when unreachable,\n"
|
||||
"so the primary is never slowed.\n"
|
||||
"State file blank → defaults to <log_dir>\\thor_forwarded_mirror.json."
|
||||
)
|
||||
tk.Label(f, text=help_text, justify="left", fg="#555555", wraplength=420).grid(
|
||||
row=4, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
|
||||
)
|
||||
|
||||
def _build_tab_updates(self, nb):
|
||||
f = self._tab_frame(nb, "Updates")
|
||||
|
||||
@@ -599,6 +656,12 @@ class SettingsDialog:
|
||||
sfm_url = ""
|
||||
sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path
|
||||
|
||||
# Mirror (dual-send) — best-effort second destination.
|
||||
mirror_api_url = self.var_mirror_api_url.get().strip()
|
||||
if mirror_api_url:
|
||||
mirror_api_url = mirror_api_url.rstrip("/") + "/api/series4/heartbeat"
|
||||
mirror_sfm_url = self.var_mirror_sfm_url.get().strip().rstrip("/")
|
||||
|
||||
values = {
|
||||
"thordata_path": self.var_thordata_path.get().strip(),
|
||||
"scan_interval": int_values["Scan Interval"],
|
||||
@@ -623,6 +686,10 @@ class SettingsDialog:
|
||||
"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(),
|
||||
|
||||
"mirror_api_url": mirror_api_url,
|
||||
"mirror_sfm_url": mirror_sfm_url,
|
||||
"mirror_sfm_state_file": self.var_mirror_sfm_state_file.get().strip(),
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user