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). - series3_watcher: MIRROR_API_URL/MIRROR_SFM_URL/MIRROR_SFM_STATE_FILE config; best-effort heartbeat mirror; isolated event-mirror pass after the primary. - settings dialog + config-template: new 'Mirror' tab / keys. - 8 new tests incl. the isolation invariant. 44 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,11 @@ DEFAULTS = {
|
||||
"SFM_HTTP_TIMEOUT": "60",
|
||||
"SFM_STATE_FILE": "",
|
||||
"SFM_MAX_FORWARDS_PER_PASS": "500",
|
||||
|
||||
# Mirror (dual-send) — best-effort second destination, default OFF.
|
||||
"MIRROR_API_URL": "",
|
||||
"MIRROR_SFM_URL": "",
|
||||
"MIRROR_SFM_STATE_FILE": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +265,14 @@ class SettingsDialog:
|
||||
self.var_sfm_state_file = tk.StringVar(value=v["SFM_STATE_FILE"])
|
||||
self.var_sfm_max_per_pass = tk.StringVar(value=v["SFM_MAX_FORWARDS_PER_PASS"])
|
||||
|
||||
# Mirror (dual-send) — best-effort second destination
|
||||
_raw_mirror = v["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=v["MIRROR_SFM_URL"])
|
||||
self.var_mirror_sfm_state_file = tk.StringVar(value=v["MIRROR_SFM_STATE_FILE"])
|
||||
|
||||
# --- UI construction ---
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -288,6 +301,7 @@ class SettingsDialog:
|
||||
self._build_tab_logging(nb)
|
||||
self._build_tab_updates(nb)
|
||||
self._build_tab_sfm(nb)
|
||||
self._build_tab_mirror(nb)
|
||||
|
||||
# Buttons
|
||||
btn_frame = tk.Frame(outer)
|
||||
@@ -566,6 +580,58 @@ class SettingsDialog:
|
||||
row=8, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
|
||||
)
|
||||
|
||||
def _build_tab_mirror(self, nb):
|
||||
"""Optional dual-send mirror: a second heartbeat + event destination."""
|
||||
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)
|
||||
|
||||
tk.Label(f, text="Mirror State File", anchor="w").grid(
|
||||
row=3, column=0, sticky="w", padx=(8, 4), pady=4
|
||||
)
|
||||
state_frame = tk.Frame(f)
|
||||
state_frame.grid(row=3, column=1, sticky="ew", padx=(0, 8), pady=4)
|
||||
state_frame.columnconfigure(0, weight=1)
|
||||
ttk.Entry(state_frame, textvariable=self.var_mirror_sfm_state_file, width=32).grid(
|
||||
row=0, column=0, sticky="ew"
|
||||
)
|
||||
|
||||
def _browse_mirror_state():
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="Mirror forward-state file",
|
||||
defaultextension=".json",
|
||||
filetypes=[("JSON", "*.json"), ("All Files", "*.*")],
|
||||
initialfile="sfm_forwarded_mirror.json",
|
||||
)
|
||||
if path:
|
||||
self.var_mirror_sfm_state_file.set(path)
|
||||
|
||||
ttk.Button(state_frame, text="Browse...", width=10,
|
||||
command=_browse_mirror_state).grid(row=0, column=1, padx=(4, 0))
|
||||
|
||||
hint_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\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>/sfm_forwarded_mirror.json."
|
||||
)
|
||||
tk.Label(f, text=hint_text, justify="left", fg="#555555", wraplength=420).grid(
|
||||
row=4, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4)
|
||||
)
|
||||
|
||||
def _test_sfm_connection(self):
|
||||
"""GET <sfm_url>/health and show the result."""
|
||||
import urllib.request
|
||||
@@ -673,6 +739,12 @@ class SettingsDialog:
|
||||
else:
|
||||
api_url = api_url.rstrip("/") + "/api/series3/heartbeat"
|
||||
|
||||
# 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/series3/heartbeat"
|
||||
mirror_sfm_url = self.var_mirror_sfm_url.get().strip().rstrip("/")
|
||||
|
||||
values = {
|
||||
"API_ENABLED": "true" if self.var_api_enabled.get() else "false",
|
||||
"API_URL": api_url,
|
||||
@@ -698,6 +770,10 @@ class SettingsDialog:
|
||||
"SFM_HTTP_TIMEOUT": str(int_values["SFM HTTP Timeout"]),
|
||||
"SFM_STATE_FILE": self.var_sfm_state_file.get().strip(),
|
||||
"SFM_MAX_FORWARDS_PER_PASS": str(int_values["SFM Max Events Per Pass"]),
|
||||
|
||||
"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