Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src-tauri/src/cloud/ws.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -127,7 +128,7 @@ pub async fn start_ws_client(
/// Parse an incoming WebSocket message and dispatch commands.
async fn handle_message<S>(
text: &str,
_cloud_state: &Arc<CloudState>,
cloud_state: &Arc<CloudState>,
app_handle: &tauri::AppHandle,
write: &mut S,
) where
Expand Down Expand Up @@ -189,6 +190,55 @@ async fn handle_message<S>(
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" });
Expand All @@ -201,3 +251,16 @@ async fn handle_message<S>(
}
}
}

/// 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<CloudState>, 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,
}
}
116 changes: 116 additions & 0 deletions src-tauri/src/commands/alerts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, String> {
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<bool, String> {
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<MinerSnapshot>) -> Result<Vec<AlertEvent>, String> {
let rules = load_rules();
Expand Down Expand Up @@ -679,3 +745,53 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec<MobileMinerSnapsho

Ok(triggered)
}

#[cfg(test)]
mod tests {
use super::*;

fn event(rule: &str, miner: &str, ts: &str) -> 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"));
}
}
19 changes: 19 additions & 0 deletions src/pages/Alerts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -94,6 +95,24 @@ export default function Alerts() {
invoke<MobileMiner[]>("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 });
Expand Down
Loading