diff --git a/src-tauri/src/cloud/ws.rs b/src-tauri/src/cloud/ws.rs index a4ba456..60cf95f 100644 --- a/src-tauri/src/cloud/ws.rs +++ b/src-tauri/src/cloud/ws.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use futures_util::{Sink, SinkExt, StreamExt}; +use tauri::Emitter; use tokio_tungstenite::tungstenite::Message; use super::{command_exec, CloudState, CloudSyncStatus}; @@ -127,7 +128,7 @@ pub async fn start_ws_client( /// Parse an incoming WebSocket message and dispatch commands. async fn handle_message( text: &str, - _cloud_state: &Arc, + cloud_state: &Arc, app_handle: &tauri::AppHandle, write: &mut S, ) where @@ -189,6 +190,55 @@ async fn handle_message( log::warn!("Cloud WS: failed to send command ack: {}", e); } } + "alert-read" => { + let data = match msg.get("data") { + Some(d) => d, + None => { + log::warn!("Cloud WS: alert-read message missing 'data' field"); + return; + } + }; + if !instance_matches(cloud_state, data) { + log::debug!("Cloud WS: alert-read for a different instance, ignoring"); + return; + } + 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(""); + + match crate::commands::alerts::mark_alert_read(rule_name, miner_id, timestamp) { + Ok(true) => { + log::info!( + "Cloud WS: alert marked read remotely (rule='{}', miner='{}')", + rule_name, + miner_id + ); + let _ = app_handle.emit("alerts-updated", ()); + } + Ok(false) => log::debug!( + "Cloud WS: alert-read had no local match (rule='{}', miner='{}', ts='{}')", + rule_name, + miner_id, + timestamp + ), + Err(e) => log::warn!("Cloud WS: failed to mark alert read: {}", e), + } + } + "alerts-read-all" => { + let data = msg.get("data").cloned().unwrap_or(serde_json::Value::Null); + if !instance_matches(cloud_state, &data) { + log::debug!("Cloud WS: alerts-read-all for a different instance, ignoring"); + return; + } + match crate::commands::alerts::mark_all_alerts_read() { + Ok(true) => { + log::info!("Cloud WS: all alerts marked read remotely"); + let _ = app_handle.emit("alerts-updated", ()); + } + Ok(false) => log::debug!("Cloud WS: alerts-read-all — nothing to update"), + Err(e) => log::warn!("Cloud WS: failed to mark all alerts read: {}", e), + } + } "ping" => { // Server-level ping (application layer), respond with pong let pong = serde_json::json!({ "type": "pong" }); @@ -201,3 +251,16 @@ async fn handle_message( } } } + +/// Whether a payload's `instanceId` refers to this instance. Lenient: if the +/// payload omits it or we don't yet know our own instance id, we don't block +/// (the socket is already scoped to this instance by its apiKey). We only +/// reject when both are known and differ. +fn instance_matches(cloud_state: &Arc, data: &serde_json::Value) -> bool { + let incoming = data.get("instanceId").and_then(|v| v.as_str()); + let ours = cloud_state.instance_id.lock().unwrap().clone(); + match (incoming, ours) { + (Some(a), Some(b)) => a == b, + _ => true, + } +} diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index 1533afa..93ec351 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -321,6 +321,72 @@ pub fn acknowledge_alert(id: String) -> Result<(), String> { save_history(&history) } +// ─── Remote read-state sync (from cloud WebSocket) ─────────────────────────── +// +// The cloud broadcasts `alert-read` / `alerts-read-all` when an alert is +// acknowledged from the web portal or mobile app. We match those against local +// alerts on (ruleName, minerId, timestamp) — the desktop does not store the +// cloud's alertId (the POST /alert response is discarded and alerts are +// delivered via an offline queue), and the local AlertEvent already carries +// 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. +fn timestamps_match(a: &str, b: &str) -> bool { + if a == b { + return true; + } + match ( + chrono::DateTime::parse_from_rfc3339(a), + chrono::DateTime::parse_from_rfc3339(b), + ) { + (Ok(da), Ok(db)) => da == db, + _ => false, + } +} + +/// Does this local event correspond to the cloud's (ruleName, minerId, timestamp)? +fn alert_matches(event: &AlertEvent, rule_name: &str, miner_id: &str, timestamp: &str) -> bool { + event.rule_name == rule_name + && event.miner_ip == miner_id + && timestamps_match(&event.timestamp, timestamp) +} + +/// Mark a single local alert acknowledged to mirror a remote read. Matches on +/// (ruleName, minerId, timestamp). Returns whether any local record changed. +pub fn mark_alert_read(rule_name: &str, miner_id: &str, timestamp: &str) -> Result { + let mut history = load_history(); + let mut changed = false; + for e in history.iter_mut() { + if !e.acknowledged && alert_matches(e, rule_name, miner_id, timestamp) { + e.acknowledged = true; + changed = true; + } + } + if changed { + save_history(&history)?; + } + Ok(changed) +} + +/// Mark all local alerts acknowledged to mirror a remote "mark all read". +/// Returns whether any local record changed. +pub fn mark_all_alerts_read() -> Result { + let mut history = load_history(); + let mut changed = false; + for e in history.iter_mut() { + if !e.acknowledged { + e.acknowledged = true; + changed = true; + } + } + if changed { + save_history(&history)?; + } + Ok(changed) +} + #[tauri::command] pub fn check_alerts(app: tauri::AppHandle, miners: Vec) -> Result, String> { let rules = load_rules(); @@ -679,3 +745,53 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec AlertEvent { + AlertEvent { + id: "local-1".to_string(), + rule_id: "r1".to_string(), + rule_name: rule.to_string(), + miner_ip: miner.to_string(), + miner_label: "Rig".to_string(), + message: "msg".to_string(), + timestamp: ts.to_string(), + acknowledged: false, + notify_desktop: true, + notify_email: false, + } + } + + #[test] + fn matches_on_rule_miner_and_timestamp() { + let e = event("High Temp", "192.168.1.5", "2026-06-07T10:00:00.123456789+00:00"); + assert!(alert_matches(&e, "High Temp", "192.168.1.5", "2026-06-07T10:00:00.123456789+00:00")); + } + + #[test] + fn rejects_on_any_field_mismatch() { + let e = event("High Temp", "192.168.1.5", "2026-06-07T10:00:00+00:00"); + assert!(!alert_matches(&e, "Low Hashrate", "192.168.1.5", "2026-06-07T10:00:00+00:00")); + assert!(!alert_matches(&e, "High Temp", "192.168.1.9", "2026-06-07T10:00:00+00:00")); + assert!(!alert_matches(&e, "High Temp", "192.168.1.5", "2026-06-07T11:00:00+00:00")); + } + + #[test] + fn timestamp_match_is_format_tolerant() { + // Same instant, different RFC-3339 spelling (Z vs +00:00) still matches. + assert!(timestamps_match("2026-06-07T10:00:00Z", "2026-06-07T10:00:00+00:00")); + assert!(timestamps_match("2026-06-07T10:00:00.500Z", "2026-06-07T10:00:00.500+00:00")); + // Different instants do not. + assert!(!timestamps_match("2026-06-07T10:00:00Z", "2026-06-07T10:00:01Z")); + } + + #[test] + fn mobile_device_id_matches_as_miner_id() { + // Mobile alerts carry the device_id in miner_ip; cloud sends it as minerId. + let e = event("OverMobile Offline", "device-abc-123", "2026-06-07T10:00:00Z"); + assert!(alert_matches(&e, "OverMobile Offline", "device-abc-123", "2026-06-07T10:00:00Z")); + } +} diff --git a/src/pages/Alerts.tsx b/src/pages/Alerts.tsx index 7826828..7901ebb 100644 --- a/src/pages/Alerts.tsx +++ b/src/pages/Alerts.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import type { AlertEvent, AlertRule, RuleType } from "../types/alerts"; @@ -94,6 +95,24 @@ export default function Alerts() { invoke("get_mobile_miners").then(setMobileMiners).catch(console.error); }, [loadHistory]); + // Reload the history table when alerts change — including when an alert is + // marked read remotely (from the web portal / mobile app), which the cloud + // WebSocket relays and the backend reflects by emitting "alerts-updated". + useEffect(() => { + let unlisten: (() => void) | null = null; + let cancelled = false; + listen("alerts-updated", () => { + loadHistory(); + }).then((h) => { + if (cancelled) h(); + else unlisten = h; + }); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [loadHistory]); + async function handleAcknowledge(id: string) { try { await invoke("acknowledge_alert", { id });