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
22 changes: 22 additions & 0 deletions src-tauri/src/cloud/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/cloud/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
56 changes: 50 additions & 6 deletions src-tauri/src/commands/alerts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::cloud::CloudState>>,
) -> 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) ───────────────────────────
Expand All @@ -331,8 +371,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;
Expand All @@ -341,7 +385,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,
}
}
Expand Down
Loading