From 603af6be25807b2567b7ad2540b9b48a9a9c6c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:31:01 +0000 Subject: [PATCH 1/2] 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/2] 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) ───────────────────────────