diff --git a/src-tauri/src/commands/alerts.rs b/src-tauri/src/commands/alerts.rs index 84d29ea..1533afa 100644 --- a/src-tauri/src/commands/alerts.rs +++ b/src-tauri/src/commands/alerts.rs @@ -370,6 +370,10 @@ pub fn check_alerts(app: tauri::AppHandle, miners: Vec) -> Result return Ok(vec![]); } + // Devices the user has explicitly muted — no alert, no notification, no + // cloud push while muted. Expired mutes are excluded automatically. + let muted = crate::commands::mute::muted_ids(now_ts * 1000); + let mut triggered: Vec = Vec::new(); let mut idx: u32 = 0; @@ -383,6 +387,9 @@ pub fn check_alerts(app: tauri::AppHandle, miners: Vec) -> Result } for miner in &miners { + if muted.contains(&miner.ip) { + continue; + } if !rule.applies_to.is_empty() && !rule.applies_to.contains(&miner.ip) { continue; } @@ -537,6 +544,9 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec = Vec::new(); let mut idx: u32 = 0; @@ -549,6 +559,9 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec= muted_until`. + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MutedDevice { + /// Epoch milliseconds at which the mute expires. `None` = until re-enabled. + #[serde(default)] + pub muted_until: Option, +} + +fn mute_path() -> PathBuf { + crate::paths::app_data_root().join("muted_devices.json") +} + +fn load_muted() -> HashMap { + let path = mute_path(); + if !path.exists() { + return HashMap::new(); + } + let content = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str(&content).unwrap_or_default() +} + +/// Persist the mute map with an atomic write (temp file + rename) so a crash +/// mid-write can never leave a truncated/corrupt `muted_devices.json`. +fn save_muted(map: &HashMap) -> Result<(), String> { + let path = mute_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let content = serde_json::to_string_pretty(map).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, content).map_err(|e| e.to_string())?; + fs::rename(&tmp, &path).map_err(|e| e.to_string()) +} + +/// Drop entries whose `muted_until` is in the past. Returns `true` if anything +/// was removed (so the caller can decide whether to persist). Idempotent. +fn prune_expired(map: &mut HashMap, now_ms: i64) -> bool { + let before = map.len(); + map.retain(|_, m| match m.muted_until { + Some(ts) => now_ms < ts, + None => true, + }); + map.len() != before +} + +/// Set of device ids that are currently muted (expired entries excluded). +/// Called by the alert engine each evaluation cycle. +pub fn muted_ids(now_ms: i64) -> HashSet { + load_muted() + .into_iter() + .filter(|(_, m)| match m.muted_until { + Some(ts) => now_ms < ts, + None => true, + }) + .map(|(id, _)| id) + .collect() +} + +// --- Tauri commands ---------------------------------------------------------- + +#[tauri::command] +pub fn get_muted_devices() -> Result, String> { + let mut map = load_muted(); + // Auto-clear expired entries so the UI never shows a stale "muted" badge. + if prune_expired(&mut map, Utc::now().timestamp_millis()) { + let _ = save_muted(&map); + } + Ok(map) +} + +#[tauri::command] +pub fn set_device_mute(id: String, muted_until: Option) -> Result<(), String> { + let mut map = load_muted(); + map.insert(id, MutedDevice { muted_until }); + save_muted(&map) +} + +#[tauri::command] +pub fn clear_device_mute(id: String) -> Result<(), String> { + let mut map = load_muted(); + map.remove(&id); + save_muted(&map) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc42345..783d024 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ use commands::alerts::{ use commands::coins::{get_coins, add_coin, remove_coin}; use commands::email::{get_smtp_config, save_smtp_config, test_smtp_config, send_alert_email}; use commands::notifications::send_desktop_notification; +use commands::mute::{get_muted_devices, set_device_mute, clear_device_mute}; use commands::uptime::{record_uptime, get_uptime_stats, get_all_uptime_stats, clear_uptime_data}; use commands::export::{export_miners_csv, export_alert_history_csv, export_profitability_csv, export_farm_history_csv}; use commands::tray::{TrayState, update_tray_tooltip}; @@ -310,6 +311,9 @@ pub fn run() { acknowledge_alert, check_alerts, check_mobile_alerts, + get_muted_devices, + set_device_mute, + clear_device_mute, get_coins, add_coin, remove_coin, diff --git a/src/components/MuteControl.tsx b/src/components/MuteControl.tsx new file mode 100644 index 0000000..ec93b80 --- /dev/null +++ b/src/components/MuteControl.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface MutedDevice { + mutedUntil: number | null; // epoch ms, or null = until re-enabled +} + +/** + * Mute / snooze alerts for a single device. `id` is the miner IP for ASICs or + * the device_id for OverMobile devices — the same key the alert engine uses. + * Renders a dropdown control and, when muted, doubles as the "Muted (until …)" + * badge so it's obvious why a device is quiet. + */ +export default function MuteControl({ + id, + className = "", +}: { + id: string; + className?: string; +}) { + const [muted, setMuted] = useState(null); + const [open, setOpen] = useState(false); + const [showCustom, setShowCustom] = useState(false); + const [customValue, setCustomValue] = useState(""); + const ref = useRef(null); + + const load = useCallback(async () => { + try { + const map = await invoke>("get_muted_devices"); + setMuted(map[id] ?? null); + } catch (err) { + console.error("Failed to load mute state:", err); + } + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + // Close the menu on an outside click. + useEffect(() => { + if (!open) return; + function onClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + setShowCustom(false); + } + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [open]); + + async function mute(mutedUntil: number | null) { + try { + await invoke("set_device_mute", { id, mutedUntil }); + setOpen(false); + setShowCustom(false); + await load(); + } catch (err) { + console.error("Failed to mute device:", err); + } + } + + async function unmute() { + try { + await invoke("clear_device_mute", { id }); + setOpen(false); + setShowCustom(false); + await load(); + } catch (err) { + console.error("Failed to unmute device:", err); + } + } + + function muteForHours(hours: number) { + mute(Date.now() + hours * 3_600_000); + } + + function applyCustom() { + if (!customValue) return; + const ts = new Date(customValue).getTime(); + if (!Number.isNaN(ts) && ts > Date.now()) mute(ts); + } + + const isMuted = muted != null; + const badgeText = !isMuted + ? "Mute alerts" + : muted!.mutedUntil == null + ? "Muted (until re-enabled)" + : `Muted until ${new Date(muted!.mutedUntil).toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`; + + const options: { label: string; action: () => void }[] = [ + { label: "1 hour", action: () => muteForHours(1) }, + { label: "4 hours", action: () => muteForHours(4) }, + { label: "24 hours", action: () => muteForHours(24) }, + { label: "Until I re-enable", action: () => mute(null) }, + ]; + + return ( +
+ + + {open && ( +
+

+ Mute alerts for +

+ {options.map((o) => ( + + ))} + {!showCustom ? ( + + ) : ( +
+ setCustomValue(e.target.value)} + className="w-full bg-dark-900 border border-slate-600 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-primary-500" + /> + +
+ )} + {isMuted && ( + <> +
+ + + )} +
+ )} +
+ ); +} diff --git a/src/pages/MinerDetail.tsx b/src/pages/MinerDetail.tsx index 9b2ef1c..e6e4ca9 100644 --- a/src/pages/MinerDetail.tsx +++ b/src/pages/MinerDetail.tsx @@ -17,6 +17,7 @@ import { profileToPayload } from "../types/miner"; import { useProfitability } from "../context/ProfitabilityContext"; import { getMinerCoinId } from "../utils/coinLookup"; import { getCoinIcon } from "../utils/coinIcon"; +import MuteControl from "../components/MuteControl"; const HEALTH_COLORS: Record = { ok: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", @@ -327,6 +328,7 @@ export default function MinerDetail() { {lastRefresh && (

Updated: {lastRefresh}

)} + -
-

{miner.name}

-

- {miner.deviceModel} · {miner.osVersion} · v{miner.appVersion} -

+
+
+

{miner.name}

+

+ {miner.deviceModel} · {miner.osVersion} · v{miner.appVersion} +

+
+
{/* Status overview */}