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
26 changes: 26 additions & 0 deletions src-tauri/src/commands/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ pub fn get_notification_status(_app: tauri::AppHandle) -> Result<String, String>
Ok("enabled".to_string())
}

/// Open the OS notification-settings page. Done in the backend via the OS
/// opener because the shell plugin's `open` URL validator rejects non-web
/// schemes like `ms-settings:`, so the frontend can't deep-link to it directly.
#[tauri::command]
pub fn open_notification_settings() -> Result<(), String> {
#[cfg(target_os = "windows")]
std::process::Command::new("explorer")
.arg("ms-settings:notifications")
.spawn()
.map_err(|e| format!("Failed to open notification settings: {}", e))?;

#[cfg(target_os = "macos")]
std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.notifications")
.spawn()
.map_err(|e| format!("Failed to open notification settings: {}", e))?;

#[cfg(target_os = "linux")]
std::process::Command::new("gnome-control-center")
.arg("notifications")
.spawn()
.map_err(|e| format!("Failed to open notification settings: {}", e))?;

Ok(())
}

#[tauri::command]
pub fn send_desktop_notification(
app: tauri::AppHandle,
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,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, get_notification_status};
use commands::notifications::{send_desktop_notification, get_notification_status, open_notification_settings};
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};
Expand Down Expand Up @@ -323,6 +323,7 @@ pub fn run() {
send_alert_email,
send_desktop_notification,
get_notification_status,
open_notification_settings,
add_farm_snapshot,
get_farm_history,
clear_farm_history,
Expand Down
7 changes: 5 additions & 2 deletions src/components/NotificationBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open as openUrl } from "@tauri-apps/plugin-shell";

/**
* Warns when Windows notifications are turned off for OverManager. When the
Expand Down Expand Up @@ -47,7 +46,11 @@ export default function NotificationBanner() {
Turn notifications back on in Windows Settings so alert toasts are delivered.
</p>
<button
onClick={() => openUrl("ms-settings:notifications")}
onClick={() =>
invoke("open_notification_settings").catch((err) =>
console.error("Failed to open notification settings:", err)
)
}
className="mt-2 px-3 py-1.5 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/40 text-amber-200 text-xs font-medium rounded-lg transition-colors"
>
Open Windows notification settings
Expand Down
49 changes: 42 additions & 7 deletions src/pages/MinerList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function MinerCard({
uptimeStats,
coinIcon,
onRemove,
isMuted = false,
}: {
miner: MinerInfo;
displayName: string;
Expand All @@ -114,6 +115,7 @@ function MinerCard({
uptimeStats?: UptimeStats;
coinIcon?: string | null;
onRemove?: () => void;
isMuted?: boolean;
}) {
const statusColor =
{
Expand Down Expand Up @@ -141,6 +143,8 @@ function MinerCard({
className={`bg-dark-800 rounded-xl border p-5 cursor-pointer transition-all relative ${
selectionMode && selected
? "border-primary-500 bg-primary-500/5"
: isMuted
? "border-amber-500/40 bg-amber-500/5 hover:border-amber-500/60"
: "border-slate-700/50 hover:border-primary-500/50 hover:bg-dark-800/80"
}`}
onClick={handleCardClick}
Expand Down Expand Up @@ -207,10 +211,14 @@ function MinerCard({
</svg>
</button>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium text-white ${statusColor}`}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
isMuted
? "bg-amber-500/15 border border-amber-500/40 text-amber-300"
: `text-white ${statusColor}`
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
{miner.status}
<span className={`w-1.5 h-1.5 rounded-full ${isMuted ? "bg-amber-400" : "bg-white/70"}`} />
{isMuted ? "Muted" : miner.status}
</span>
{onRemove && (
<button
Expand Down Expand Up @@ -305,6 +313,7 @@ function MinerTable({
uptimeStats,
coinIconByIp,
onRemove,
muted,
}: {
data: MinerWithSaved[];
selectedIps: Set<string>;
Expand All @@ -316,6 +325,7 @@ function MinerTable({
uptimeStats: Record<string, UptimeStats>;
coinIconByIp?: Record<string, string | null>;
onRemove?: (ip: string) => void;
muted: Record<string, { mutedUntil: number | null }>;
}) {
const statusBg = (status: string) =>
({
Expand Down Expand Up @@ -449,8 +459,11 @@ function MinerTable({
? Math.max(...info.boards.map((b) => b.inTmp))
: null;
const activePool = info.pools.find((p) => p.connect);
const isMuted = !!muted[info.ip];
const rowBg = isSelected
? "bg-primary-500/10 hover:bg-primary-500/15"
: isMuted
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-2 border-l-amber-500/50"
: i % 2 === 0
? "bg-dark-800 hover:bg-dark-700/50"
: "bg-dark-800/60 hover:bg-dark-700/50";
Expand Down Expand Up @@ -499,10 +512,14 @@ function MinerTable({
<td className="px-4 py-3 text-sm text-slate-400 font-mono">{info.ip}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium text-white ${statusBg(info.status)}`}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
isMuted
? "bg-amber-500/15 border border-amber-500/40 text-amber-300"
: `text-white ${statusBg(info.status)}`
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
{info.status}
<span className={`w-1.5 h-1.5 rounded-full ${isMuted ? "bg-amber-400" : "bg-white/70"}`} />
{isMuted ? "Muted" : info.status}
</span>
</td>
<td className="px-4 py-3 hidden md:table-cell">
Expand Down Expand Up @@ -611,8 +628,19 @@ export default function MinerList() {

const [allUptimeStats, setAllUptimeStats] = useState<Record<string, UptimeStats>>({});

const [muted, setMuted] = useState<Record<string, { mutedUntil: number | null }>>({});

const [showAddPanel, setShowAddPanel] = useState(false);

const loadMuted = useCallback(async () => {
try {
const map = await invoke<Record<string, { mutedUntil: number | null }>>("get_muted_devices");
setMuted(map);
} catch (err) {
console.error("Failed to load muted devices:", err);
}
}, []);

// Sync coin filter if URL param changes
useEffect(() => {
const coinParam = searchParams.get("coin");
Expand All @@ -625,6 +653,10 @@ export default function MinerList() {
.catch(console.error);
}, []);

useEffect(() => {
loadMuted();
}, [loadMuted]);

const loadFromCache = useCallback(async (saved: SavedMiner[]) => {
try {
const [cached, pollTime] = await Promise.all([
Expand Down Expand Up @@ -692,6 +724,7 @@ export default function MinerList() {
let cancelled = false;
listen("farm-state-updated", () => {
loadFromCache(savedMiners).catch(console.error);
loadMuted();
}).then((h) => {
if (cancelled) h();
else unlisten = h;
Expand All @@ -700,7 +733,7 @@ export default function MinerList() {
cancelled = true;
if (unlisten) unlisten();
};
}, [savedMiners, loadFromCache]);
}, [savedMiners, loadFromCache, loadMuted]);

async function handleManualRefresh() {
setRefreshing(true);
Expand Down Expand Up @@ -1325,6 +1358,7 @@ export default function MinerList() {
uptimeStats={allUptimeStats}
coinIconByIp={coinIconByIp}
onRemove={(ip) => openRemoveModal([ip])}
muted={muted}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
Expand All @@ -1340,6 +1374,7 @@ export default function MinerList() {
uptimeStats={allUptimeStats[d.info.ip]}
coinIcon={coinIconByIp[d.info.ip]}
onRemove={() => openRemoveModal([d.info.ip])}
isMuted={!!muted[d.info.ip]}
/>
))}
</div>
Expand Down
46 changes: 39 additions & 7 deletions src/pages/MobileMinerList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function MobileMinerCard({
onRemove,
history,
coinIcon,
isMuted = false,
}: {
miner: MobileMiner;
onClick: () => void;
Expand All @@ -159,6 +160,7 @@ function MobileMinerCard({
onRemove: () => void;
history: HistoryPoint[];
coinIcon: string | null;
isMuted?: boolean;
}) {
const onlineState = deriveOnlineState(miner);
const statusColor = {
Expand All @@ -182,6 +184,8 @@ function MobileMinerCard({
className={`bg-dark-800 rounded-xl border p-5 cursor-pointer transition-all relative ${
selectionMode && selected
? "border-primary-500 bg-primary-500/5"
: isMuted
? "border-amber-500/40 bg-amber-500/5 hover:border-amber-500/60"
: "border-slate-700/50 hover:border-primary-500/50 hover:bg-dark-800/80"
}`}
onClick={handleCardClick}
Expand Down Expand Up @@ -235,10 +239,14 @@ function MobileMinerCard({
{!selectionMode && (
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium text-white ${statusColor}`}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
isMuted
? "bg-amber-500/15 border border-amber-500/40 text-amber-300"
: `text-white ${statusColor}`
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
{statusLabel}
<span className={`w-1.5 h-1.5 rounded-full ${isMuted ? "bg-amber-400" : "bg-white/70"}`} />
{isMuted ? "Muted" : statusLabel}
</span>
<button
onClick={(e) => {
Expand Down Expand Up @@ -333,6 +341,7 @@ function MobileMinerTable({
onSort,
coinIconByDevice,
onRemove,
muted,
}: {
data: MobileMiner[];
selectedDeviceIds: Set<string>;
Expand All @@ -343,6 +352,7 @@ function MobileMinerTable({
onSort: (col: string) => void;
coinIconByDevice: Record<string, string | null>;
onRemove?: (miner: MobileMiner) => void;
muted: Record<string, { mutedUntil: number | null }>;
}) {
const statusBg = (miner: MobileMiner) => {
const state = deriveOnlineState(miner);
Expand Down Expand Up @@ -478,8 +488,11 @@ function MobileMinerTable({
<tbody>
{data.map((miner, i) => {
const isSelected = selectedDeviceIds.has(miner.deviceId);
const isMuted = !!muted[miner.deviceId];
const rowBg = isSelected
? "bg-primary-500/10 hover:bg-primary-500/15"
: isMuted
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-2 border-l-amber-500/50"
: i % 2 === 0
? "bg-dark-800 hover:bg-dark-700/50"
: "bg-dark-800/60 hover:bg-dark-700/50";
Expand Down Expand Up @@ -555,10 +568,14 @@ function MobileMinerTable({
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium text-white ${statusBg(miner)}`}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
isMuted
? "bg-amber-500/15 border border-amber-500/40 text-amber-300"
: `text-white ${statusBg(miner)}`
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white/70" />
{statusLabel}
<span className={`w-1.5 h-1.5 rounded-full ${isMuted ? "bg-amber-400" : "bg-white/70"}`} />
{isMuted ? "Muted" : statusLabel}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-400 hidden xl:table-cell">
Expand Down Expand Up @@ -629,8 +646,19 @@ export default function MobileMinerList() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<Set<string>>(new Set());
const [bulkRunning, setBulkRunning] = useState(false);

const [muted, setMuted] = useState<Record<string, { mutedUntil: number | null }>>({});

const historyRef = useRef<Map<string, HistoryPoint[]>>(new Map());

const loadMuted = useCallback(async () => {
try {
const map = await invoke<Record<string, { mutedUntil: number | null }>>("get_muted_devices");
setMuted(map);
} catch (err) {
console.error("Failed to load muted devices:", err);
}
}, []);

const fetchMiners = useCallback(async () => {
try {
const list = await invoke<MobileMiner[]>("get_mobile_miners");
Expand Down Expand Up @@ -754,11 +782,13 @@ export default function MobileMinerList() {

useEffect(() => {
fetchMiners();
loadMuted();
invoke<string>("get_mobile_server_url").then(setServerUrl).catch(console.error);
let unlisten: (() => void) | null = null;
let cancelled = false;
listen("farm-state-updated", () => {
fetchMiners();
loadMuted();
}).then((h) => {
if (cancelled) h();
else unlisten = h;
Expand All @@ -767,7 +797,7 @@ export default function MobileMinerList() {
cancelled = true;
if (unlisten) unlisten();
};
}, [fetchMiners]);
}, [fetchMiners, loadMuted]);

async function handleManualRefresh() {
setRefreshing(true);
Expand Down Expand Up @@ -1340,6 +1370,7 @@ export default function MobileMinerList() {
onSort={handleSort}
coinIconByDevice={coinIconByDevice}
onRemove={(m) => { setRemoveError(null); setRemoveTarget(m); }}
muted={muted}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
Expand All @@ -1356,6 +1387,7 @@ export default function MobileMinerList() {
onRemove={() => { setRemoveError(null); setRemoveTarget(m); }}
history={historyRef.current.get(m.deviceId) ?? []}
coinIcon={coinIconByDevice[m.deviceId]}
isMuted={!!muted[m.deviceId]}
/>
))}
</div>
Expand Down
Loading