From 603af6be25807b2567b7ad2540b9b48a9a9c6c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:31:01 +0000 Subject: [PATCH 1/5] fix(alerts): match cloud read-state at second granularity for ack sync https://claude.ai/code/session_01YYr4XG3SBHoSpP33d84Jqa --- src-tauri/src/commands/alerts.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index 93ec351..fb522e8 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -331,8 +331,12 @@ pub fn acknowledge_alert(id: String) -> Result<(), String> { // exactly these three fields: `rule_name`, `miner_ip` (the cloud's `minerId`), // and `timestamp`. The cloud echoes them in the payload for this purpose. -/// Compare two RFC-3339 timestamps. Falls back to instant equality so a format -/// difference (e.g. `Z` vs `+00:00`) doesn't prevent a match. +/// Compare two RFC-3339 timestamps at whole-second granularity. The desktop +/// stores nanosecond precision, but a timestamp that round-trips through the +/// cloud (Postgres truncates to microseconds; the WS broadcast re-encodes at +/// millisecond precision) loses sub-second digits. Matching on the whole +/// second is safe: alert cooldowns (15–30 min) guarantee the same rule+miner +/// can't fire twice within one second. Falls back to string equality. fn timestamps_match(a: &str, b: &str) -> bool { if a == b { return true; @@ -341,7 +345,7 @@ fn timestamps_match(a: &str, b: &str) -> bool { chrono::DateTime::parse_from_rfc3339(a), chrono::DateTime::parse_from_rfc3339(b), ) { - (Ok(da), Ok(db)) => da == db, + (Ok(da), Ok(db)) => da.timestamp() == db.timestamp(), _ => false, } } From c5f43bd8ff8beae6397e53adb54c48b1cdc6fcc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 12:09:10 +0000 Subject: [PATCH 2/5] feat(alerts): push local ack to cloud so read-state syncs to portal/mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acknowledge_alert previously only flipped the local flag — nothing reached the cloud, so a read marked on the desktop never showed up on the web portal or mobile app. It now POSTs the alert's (ruleName, minerIp, timestamp) tuple to the new /ingest/alert-read endpoint after marking it locally, falling back to the offline queue (drained by the sync loop) when offline or on failure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YYr4XG3SBHoSpP33d84Jqa --- src-tauri/src/cloud/client.rs | 22 +++++++++++++++ src-tauri/src/cloud/sync.rs | 5 ++++ src-tauri/src/commands/alerts.rs | 46 +++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cloud/client.rs b/src-tauri/src/cloud/client.rs index 5f5c612..7ae19ff 100644 --- a/src-tauri/src/cloud/client.rs +++ b/src-tauri/src/cloud/client.rs @@ -229,6 +229,28 @@ pub async fn push_alert(api_key: &str, payload: &serde_json::Value) -> Result<() Ok(()) } +/// Propagate a local "mark alert read" to the cloud so the portal and mobile +/// app reflect it. The cloud matches the alert by tuple (ruleName, minerId, +/// timestamp) — the desktop never stores the cloud's surrogate alertId. +pub async fn push_alert_read(api_key: &str, payload: &serde_json::Value) -> Result<(), String> { + let client = http_client()?; + let resp = client + .post(api_url("/api/v1/ingest/alert-read")) + .header("X-API-Key", api_key) + .json(payload) + .send() + .await + .map_err(|e| format!("Alert-read push failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Alert-read push failed ({}): {}", status, body)); + } + + Ok(()) +} + pub async fn push_miners(api_key: &str, payload: &serde_json::Value) -> Result<(), String> { let client = http_client()?; let resp = client diff --git a/src-tauri/src/cloud/sync.rs b/src-tauri/src/cloud/sync.rs index f3c75e7..20de43d 100644 --- a/src-tauri/src/cloud/sync.rs +++ b/src-tauri/src/cloud/sync.rs @@ -107,6 +107,11 @@ pub async fn start_sync_loop( serde_json::from_str(&item.payload_json).unwrap_or_default(); client::push_alert(&api_key, &payload).await } + "alert-read" => { + let payload: serde_json::Value = + serde_json::from_str(&item.payload_json).unwrap_or_default(); + client::push_alert_read(&api_key, &payload).await + } "miners" => { let payload: serde_json::Value = serde_json::from_str(&item.payload_json).unwrap_or_default(); diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index fb522e8..827e92b 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -313,12 +313,52 @@ pub fn clear_alert_history() -> Result<(), String> { } #[tauri::command] -pub fn acknowledge_alert(id: String) -> Result<(), String> { +pub async fn acknowledge_alert( + id: String, + state: tauri::State<'_, Arc>, +) -> Result<(), String> { + // 1. Mark the alert read locally, capturing its identity tuple so we can + // mirror the read-state to the cloud (and thus the portal / mobile app). + // Only newly-acked alerts need to be pushed. let mut history = load_history(); + let mut tuple: Option<(String, String, String)> = None; if let Some(e) = history.iter_mut().find(|e| e.id == id) { - e.acknowledged = true; + if !e.acknowledged { + e.acknowledged = true; + tuple = Some((e.rule_name.clone(), e.miner_ip.clone(), e.timestamp.clone())); + } + } + save_history(&history)?; + + // 2. Propagate to the cloud. It matches by (ruleName, minerId, timestamp); + // `minerIp` is the desktop's miner key, which the cloud accepts as a + // minerId alias. Best-effort — queue for the sync loop to drain on + // failure or when offline so it eventually reaches the cloud. + if let Some((rule_name, miner_ip, timestamp)) = tuple { + let payload = serde_json::json!({ + "ruleName": rule_name, + "minerIp": miner_ip, + "timestamp": timestamp, + }); + let api_key = state.api_key.lock().unwrap().clone(); + let pushed = match api_key { + Some(key) => match crate::cloud::client::push_alert_read(&key, &payload).await { + Ok(()) => true, + Err(e) => { + log::warn!("Cloud: alert-read push failed, queueing — {}", e); + false + } + }, + None => false, + }; + if !pushed { + if let Err(qe) = crate::cloud::queue::enqueue("alert-read", &payload) { + log::warn!("Cloud: failed to enqueue alert-read: {}", qe); + } + } } - save_history(&history) + + Ok(()) } // ─── Remote read-state sync (from cloud WebSocket) ─────────────────────────── From 9ad01084524a950ed5b8ffb3fdb7b517b3eaff7b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:09:36 +0000 Subject: [PATCH 3/5] fix(alerts): log alert-read push outcome; allow alert-read in offline queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acknowledge_alert always returned Ok(()) regardless of whether the cloud push succeeded, and the success path had no logging at all — making it impossible to tell from the console whether an ack ever reached the cloud. Also the offline queue's CHECK constraint never included 'alert-read' (or 'alerts-read-all'), so a queued ack while offline would silently fail to insert. --- src-tauri/src/cloud/queue.rs | 15 ++++++++------- src-tauri/src/commands/alerts.rs | 15 +++++++++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/cloud/queue.rs b/src-tauri/src/cloud/queue.rs index 64e50a5..df6833f 100644 --- a/src-tauri/src/cloud/queue.rs +++ b/src-tauri/src/cloud/queue.rs @@ -23,7 +23,7 @@ pub fn open_queue() -> Result { conn.execute_batch( "CREATE TABLE IF NOT EXISTS cloud_sync_queue ( id INTEGER PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('snapshot', 'alert', 'miners')), + kind TEXT NOT NULL CHECK (kind IN ('snapshot', 'alert', 'miners', 'alert-read', 'alerts-read-all')), payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, @@ -34,9 +34,10 @@ pub fn open_queue() -> Result { ON cloud_sync_queue(created_at);" ).map_err(|e| format!("Failed to create queue table: {}", e))?; - // Migration: older installs created the table with a CHECK that allowed - // only ('snapshot', 'alert'). SQLite can't ALTER a CHECK constraint, so - // rebuild the table when the existing definition doesn't include 'miners'. + // Migration: older installs created the table with a CHECK that didn't + // allow every kind the sync loop can now enqueue. SQLite can't ALTER a + // CHECK constraint, so rebuild the table when the existing definition is + // missing any of the current kinds. let existing_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_sync_queue'", @@ -44,13 +45,13 @@ pub fn open_queue() -> Result { |row| row.get(0), ) .unwrap_or_default(); - if !existing_sql.is_empty() && !existing_sql.contains("'miners'") { - log::info!("Cloud queue: migrating CHECK constraint to allow 'miners' kind"); + if !existing_sql.is_empty() && !existing_sql.contains("'alert-read'") { + log::info!("Cloud queue: migrating CHECK constraint to allow 'alert-read'/'alerts-read-all' kinds"); conn.execute_batch( "BEGIN; CREATE TABLE cloud_sync_queue_new ( id INTEGER PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('snapshot', 'alert', 'miners')), + kind TEXT NOT NULL CHECK (kind IN ('snapshot', 'alert', 'miners', 'alert-read', 'alerts-read-all')), payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index 827e92b..217fa5a 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -343,17 +343,24 @@ pub async fn acknowledge_alert( let api_key = state.api_key.lock().unwrap().clone(); let pushed = match api_key { Some(key) => match crate::cloud::client::push_alert_read(&key, &payload).await { - Ok(()) => true, + Ok(()) => { + log::info!("Cloud: alert-read pushed successfully ({})", rule_name); + true + } Err(e) => { log::warn!("Cloud: alert-read push failed, queueing — {}", e); false } }, - None => false, + None => { + log::warn!("Cloud: no api_key set, queueing alert-read instead of pushing"); + false + } }; if !pushed { - if let Err(qe) = crate::cloud::queue::enqueue("alert-read", &payload) { - log::warn!("Cloud: failed to enqueue alert-read: {}", qe); + match crate::cloud::queue::enqueue("alert-read", &payload) { + Ok(()) => log::info!("Cloud: enqueued alert-read for sync"), + Err(qe) => log::warn!("Cloud: failed to enqueue alert-read: {}", qe), } } } From e98089a817171678ce9cb02812953eab3b9bee5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:05:12 +0000 Subject: [PATCH 4/5] fix(alerts): shorten startup grace from 5min to 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 5-minute suppression window after launch made the alerting system look broken to users who'd just opened the app — no alerts fire, no ack to test. 30s is enough for a poll cycle or two to establish a baseline for stateful rules (NoShares, MinerOffline) without the long dead zone. --- src-tauri/src/commands/alerts.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index 217fa5a..cf1197c 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -102,9 +102,11 @@ static SHARE_TRACKER: Mutex>> = Mutex::ne // Startup grace period — alerts are suppressed for the first N seconds after // the process starts so that stateful checks (NoShares, MinerOffline, etc.) // have time to warm up from a cold boot. Prevents alert storms when PoPManager -// is restarted while miners are running normally. +// is restarted while miners are running normally. Kept short (30s) so a user +// who just launched the app isn't left wondering why nothing fires — that's +// long enough for one or two poll cycles to establish a baseline. static STARTUP_INSTANT: Mutex> = Mutex::new(None); -const STARTUP_GRACE_SECONDS: u64 = 300; // 5 minutes +const STARTUP_GRACE_SECONDS: u64 = 30; fn within_startup_grace() -> (bool, u64) { let mut guard = match STARTUP_INSTANT.lock() { From 6f408ad07c130106dc3ff8a95c6cc78d20873446 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:48:26 +0000 Subject: [PATCH 5/5] fix(alerts): surface silent failure paths in individual alert-read sync Elevate the cloud->desktop "no local match" branch from debug to warn (was indistinguishable from "message never arrived" at default log level) and log receipt of every alert-read WS message. Also log the two no-op paths in acknowledge_alert (already-acked, unknown id) that previously failed silently with zero console output. --- src-tauri/src/cloud/ws.rs | 12 +++++++++++- src-tauri/src/commands/alerts.rs | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cloud/ws.rs b/src-tauri/src/cloud/ws.rs index 60cf95f..81d8698 100644 --- a/src-tauri/src/cloud/ws.rs +++ b/src-tauri/src/cloud/ws.rs @@ -205,6 +205,12 @@ async fn handle_message( let rule_name = data.get("ruleName").and_then(|v| v.as_str()).unwrap_or(""); let miner_id = data.get("minerId").and_then(|v| v.as_str()).unwrap_or(""); let timestamp = data.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + log::info!( + "Cloud WS: received alert-read (rule='{}', miner='{}', ts='{}')", + rule_name, + miner_id, + timestamp + ); match crate::commands::alerts::mark_alert_read(rule_name, miner_id, timestamp) { Ok(true) => { @@ -215,7 +221,11 @@ async fn handle_message( ); let _ = app_handle.emit("alerts-updated", ()); } - Ok(false) => log::debug!( + // Was debug-level — invisible at the default log level, which made + // this branch indistinguishable from "message never arrived" when + // debugging individual-read sync. Bump to warn with the full tuple + // so a mismatch (e.g. minerId/timestamp drift) is diagnosable. + Ok(false) => log::warn!( "Cloud WS: alert-read had no local match (rule='{}', miner='{}', ts='{}')", rule_name, miner_id, diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index cf1197c..4a2bbfc 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -324,11 +324,13 @@ pub async fn acknowledge_alert( // Only newly-acked alerts need to be pushed. let mut history = load_history(); let mut tuple: Option<(String, String, String)> = None; - if let Some(e) = history.iter_mut().find(|e| e.id == id) { - if !e.acknowledged { + match history.iter_mut().find(|e| e.id == id) { + Some(e) if !e.acknowledged => { e.acknowledged = true; tuple = Some((e.rule_name.clone(), e.miner_ip.clone(), e.timestamp.clone())); } + Some(_) => log::debug!("Cloud: alert {} already acknowledged locally, no-op", id), + None => log::warn!("Cloud: acknowledge_alert called with unknown id {}", id), } save_history(&history)?;