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
13 changes: 13 additions & 0 deletions src-tauri/src/commands/alerts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ pub fn check_alerts(app: tauri::AppHandle, miners: Vec<MinerSnapshot>) -> 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<AlertEvent> = Vec::new();
let mut idx: u32 = 0;

Expand All @@ -383,6 +387,9 @@ pub fn check_alerts(app: tauri::AppHandle, miners: Vec<MinerSnapshot>) -> Result
}

for miner in &miners {
if muted.contains(&miner.ip) {
continue;
}
if !rule.applies_to.is_empty() && !rule.applies_to.contains(&miner.ip) {
continue;
}
Expand Down Expand Up @@ -537,6 +544,9 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec<MobileMinerSnapsho
return Ok(vec![]);
}

// Devices the user has explicitly muted — skipped like in check_alerts.
let muted = crate::commands::mute::muted_ids(now_ts * 1000);

let mut triggered: Vec<AlertEvent> = Vec::new();
let mut idx: u32 = 0;

Expand All @@ -549,6 +559,9 @@ pub fn check_mobile_alerts(app: tauri::AppHandle, miners: Vec<MobileMinerSnapsho
}

for miner in &miners {
if muted.contains(&miner.device_id) {
continue;
}
if !rule.applies_to.is_empty() && !rule.applies_to.contains(&miner.device_id) {
continue;
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod email;
pub mod export;
pub mod history;
pub mod miner;
pub mod mute;
pub mod notifications;
pub mod pool;
pub mod pool_profiles;
Expand Down
100 changes: 100 additions & 0 deletions src-tauri/src/commands/mute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Per-device alert muting (snooze).
//!
//! Lets a user silence alerts for a device that is intentionally down (e.g. a
//! mobile miner turned off on purpose) without removing it. State is persisted
//! to `muted_devices.json` as a map keyed by device identifier — the miner IP
//! for ASICs, the `device_id` for OverMobile devices.
//!
//! `muted_until`:
//! - `None` → muted until the user explicitly re-enables (permanent).
//! - `Some(epoch_ms)` → auto-expires once `now >= 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<i64>,
}

fn mute_path() -> PathBuf {
crate::paths::app_data_root().join("muted_devices.json")
}

fn load_muted() -> HashMap<String, MutedDevice> {
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<String, MutedDevice>) -> 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<String, MutedDevice>, 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<String> {
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<HashMap<String, MutedDevice>, 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<i64>) -> 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)
}
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down
190 changes: 190 additions & 0 deletions src/components/MuteControl.tsx
Original file line number Diff line number Diff line change
@@ -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<MutedDevice | null>(null);
const [open, setOpen] = useState(false);
const [showCustom, setShowCustom] = useState(false);
const [customValue, setCustomValue] = useState("");
const ref = useRef<HTMLDivElement>(null);

const load = useCallback(async () => {
try {
const map = await invoke<Record<string, MutedDevice>>("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 (
<div className={`relative ${className}`} ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className={`flex items-center gap-1.5 px-3 py-1.5 border text-xs font-medium rounded-lg transition-colors ${
isMuted
? "bg-amber-500/15 border-amber-500/40 text-amber-300 hover:bg-amber-500/25"
: "bg-dark-800 border-slate-700/50 text-slate-300 hover:text-white hover:border-primary-500/50"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isMuted ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zM17 14l4-4m0 4l-4-4"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
)}
</svg>
{badgeText}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>

{open && (
<div className="absolute right-0 mt-1 w-56 z-20 bg-dark-800 border border-slate-700/60 rounded-lg shadow-xl py-1 text-sm">
<p className="px-3 py-1.5 text-xs text-slate-500 uppercase tracking-wider">
Mute alerts for
</p>
{options.map((o) => (
<button
key={o.label}
onClick={o.action}
className="w-full text-left px-3 py-1.5 text-slate-300 hover:bg-slate-700/40 hover:text-white transition-colors"
>
{o.label}
</button>
))}
{!showCustom ? (
<button
onClick={() => setShowCustom(true)}
className="w-full text-left px-3 py-1.5 text-slate-300 hover:bg-slate-700/40 hover:text-white transition-colors"
>
Custom date/time…
</button>
) : (
<div className="px-3 py-2 space-y-2">
<input
type="datetime-local"
value={customValue}
onChange={(e) => 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"
/>
<button
onClick={applyCustom}
disabled={!customValue}
className="w-full px-2 py-1 bg-primary-600 hover:bg-primary-700 disabled:opacity-40 text-white text-xs font-medium rounded transition-colors"
>
Mute until selected time
</button>
</div>
)}
{isMuted && (
<>
<div className="my-1 border-t border-slate-700/50" />
<button
onClick={unmute}
className="w-full text-left px-3 py-1.5 text-emerald-400 hover:bg-slate-700/40 hover:text-emerald-300 transition-colors"
>
Re-enable alerts
</button>
</>
)}
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/pages/MinerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
ok: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
Expand Down Expand Up @@ -327,6 +328,7 @@ export default function MinerDetail() {
{lastRefresh && (
<p className="text-xs text-slate-500">Updated: {lastRefresh}</p>
)}
<MuteControl id={decodedIp} />
<button
onClick={fetchStatus}
disabled={refreshing}
Expand Down
Loading
Loading