From 3e199f9ab6b04167e6d3d89c89cfafd416969290 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 3 Jul 2026 19:08:50 +0200 Subject: [PATCH] feat: Cluster alert implemented --- app.go | 142 +++++++++++ frontend/src/App.tsx | 38 +++ frontend/src/components/AlertsModal.tsx | 256 ++++++++++++++++++++ frontend/wailsjs/go/main/App.d.ts | 11 + frontend/wailsjs/go/main/App.js | 20 ++ frontend/wailsjs/go/models.ts | 47 ++++ internal/alerts/alerts.go | 309 ++++++++++++++++++++++++ 7 files changed, 823 insertions(+) create mode 100644 frontend/src/components/AlertsModal.tsx create mode 100644 internal/alerts/alerts.go diff --git a/app.go b/app.go index cfc6810..f1aaed1 100644 --- a/app.go +++ b/app.go @@ -18,6 +18,7 @@ import ( "time" "hamlog/internal/adif" + "hamlog/internal/alerts" "hamlog/internal/antgenius" "hamlog/internal/applog" "hamlog/internal/audio" @@ -415,6 +416,8 @@ type App struct { netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …) + alertStore *alerts.Store // DX-cluster spot alert rules (global JSON) + cwMu sync.Mutex // guards the CW decoder lifecycle cwStop chan struct{} // stops the CW decoder capture loop; nil when off cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) @@ -764,6 +767,8 @@ func (a *App) startup(ctx context.Context) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:spot", s) } + // Fire any matching alert rules (sound / visual / e-mail). + a.evaluateAlerts(s) // Mirror the spot onto the FlexRadio panadapter when enabled. The // Color is left to the backend default for now — status-based // colouring can be filled in here later (new entity / worked / …). @@ -851,6 +856,13 @@ func (a *App) startup(ctx context.Context) { a.netStore = ns } + // DX-cluster spot alert rules (global JSON). + if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil { + applog.Printf("alerts: open failed: %v", err) + } else { + a.alertStore = as + } + // Ultrabeam antenna: connect in the background if enabled. a.startUltrabeam() // Antenna Genius switch: connect in the background if enabled. @@ -4262,6 +4274,136 @@ func (a *App) toast(msg string) { } } +// ── DX-cluster spot alerts ───────────────────────────────────────────────── + +const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from) + +// ListAlertRules returns every alert rule. +func (a *App) ListAlertRules() []alerts.Rule { + if a.alertStore == nil { + return nil + } + return a.alertStore.List() +} + +// SaveAlertRule upserts a rule (empty id → create) and returns it. +func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) { + if a.alertStore == nil { + return alerts.Rule{}, fmt.Errorf("alert store unavailable") + } + return a.alertStore.Save(r) +} + +// DeleteAlertRule removes a rule by id. +func (a *App) DeleteAlertRule(id string) error { + if a.alertStore == nil { + return fmt.Errorf("alert store unavailable") + } + return a.alertStore.Delete(id) +} + +// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from). +func (a *App) GetAlertEmailTo() string { + if a.settings == nil { + return "" + } + v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo) + return v +} + +// SetAlertEmailTo stores the alert e-mail recipient. +func (a *App) SetAlertEmailTo(addr string) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr)) +} + +// evaluateAlerts runs a spot through the rules and fires notifications for every +// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no +// rules exist; the worked-before check only runs for rules that opt into it and +// already matched, so it's off the hot path. +func (a *App) evaluateAlerts(s cluster.Spot) { + if a.alertStore == nil { + return + } + sp := alerts.Spot{ + DXCall: s.DXCall, + Band: s.Band, + Mode: alerts.InferMode(s.Comment, s.FreqHz), + Country: s.Country, + Continent: s.Continent, + Spotter: s.Spotter, + } + // The spotter's country/continent (cty.dat) — resolved lazily for Origin rules. + if a.dxcc != nil && s.Spotter != "" { + if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil { + sp.SpotterCountry = m.Entity.Name + sp.SpotterContinent = m.Continent + } + } + matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode) + for _, mt := range matches { + if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) { + wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{ + "rule": mt.Rule.Name, + "call": s.DXCall, + "band": s.Band, + "mode": sp.Mode, + "freq_hz": s.FreqHz, + "country": s.Country, + "comment": s.Comment, + "sound": mt.Rule.Sound, + "visual": mt.Rule.Visual, + }) + } + if mt.Rule.Email { + go a.sendAlertEmail(mt.Rule, s, sp.Mode) + } + } +} + +// isWorkedBandMode reports whether the exact callsign is already logged on the +// given band+mode (used by rules with "skip worked before"). +func (a *App) isWorkedBandMode(call, band, mode string) bool { + if a.qso == nil || strings.TrimSpace(call) == "" { + return false + } + rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5}) + if err != nil { + return false + } + call = strings.ToUpper(strings.TrimSpace(call)) + for _, q := range rows { + if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call { + return true + } + } + return false +} + +// sendAlertEmail notifies the operator by e-mail that a wanted station was +// spotted. Best effort; requires SMTP configured (E-mail settings). +func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) { + es, _ := a.GetEmailSettings() + to := strings.TrimSpace(a.GetAlertEmailTo()) + if to == "" { + to = strings.TrimSpace(es.From) + } + if to == "" { + applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name) + return + } + subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band) + body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n", + r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment) + if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil { + applog.Printf("alert: e-mail to %s failed: %v", to, err) + } else { + applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to) + } +} + // sanitizeFilename makes a callsign safe for a filename (slashes etc.). func sanitizeFilename(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 804b30c..1104598 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; +import { AlertsModal } from '@/components/AlertsModal'; import { BulkEditModal } from '@/components/BulkEditModal'; import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; @@ -730,6 +731,8 @@ export default function App() { } const chatShown = chatOpen && chatAvailable; + const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal + // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); @@ -1100,6 +1103,35 @@ export default function App() { return () => { off(); }; }, [showToast]); + // DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no + // asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions. + useEffect(() => { + const off = EventsOn('alert:fired', (p: any) => { + if (p?.sound) { + try { + const AC = (window as any).AudioContext || (window as any).webkitAudioContext; + const ctx = new AC(); + const beep = (freq: number, at: number, dur: number) => { + const o = ctx.createOscillator(); const g = ctx.createGain(); + o.type = 'sine'; o.frequency.value = freq; + o.connect(g); g.connect(ctx.destination); + g.gain.setValueAtTime(0.0001, ctx.currentTime + at); + g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur); + o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02); + }; + beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp + window.setTimeout(() => ctx.close().catch(() => {}), 600); + } catch { /* audio blocked — ignore */ } + } + if (p?.visual) { + const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert'); + showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`); + } + }); + return () => { off(); }; + }, [showToast]); + // Poll PstRotator for the live antenna heading (status bar). Cheap when the // rotator is disabled (the backend just reads settings and returns). useEffect(() => { @@ -2209,6 +2241,7 @@ export default function App() { { type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' }, { type: 'separator' }, { type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' }, + { type: 'item', label: 'Alert management…', action: 'tools.alerts' }, { type: 'separator' }, // Maintenance — bumped here while we only have one entry. Will move // to a Tools → Maintenance submenu once Clublog + LoTW refresh land. @@ -2237,6 +2270,7 @@ export default function App() { case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; + case 'tools.alerts': setAlertsOpen(true); break; case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.downloadRefs': downloadRefs(); break; case 'help.about': setShowAbout(true); break; @@ -4101,6 +4135,10 @@ export default function App() { /> )} + {alertsOpen && ( + setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} /> + )} + showToast(`OpsLog QSL sent to ${call}`)} onError={(msg) => showToast(msg)} diff --git a/frontend/src/components/AlertsModal.tsx b/frontend/src/components/AlertsModal.tsx new file mode 100644 index 0000000..4dcbebc --- /dev/null +++ b/frontend/src/components/AlertsModal.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; +import { + ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo, +} from '@/../wailsjs/go/main/App'; +import { alerts } from '@/../wailsjs/go/models'; + +type Rule = alerts.Rule; + +const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA']; + +function emptyRule(): Rule { + return alerts.Rule.createFrom({ + id: '', name: 'New alert', enabled: true, + calls: [], countries: [], continents: [], bands: [], modes: [], + spotter_call: '', spotter_continents: [], spotter_countries: [], + sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false, + }); +} + +// MultiCheck — a scrollable checkbox list for a set of options with an optional +// search box (used for the long country list). Empty selection = ALL. +function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: { + options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string; +}) { + const [q, setQ] = useState(''); + const shown = useMemo( + () => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options), + [options, q], + ); + const sel = new Set((selected ?? []).map((s) => s.toLowerCase())); + return ( +
+ {searchable && ( +
+ + setQ(e.target.value)} /> +
+ )} +
+ {shown.map((o) => ( + + ))} + {shown.length === 0 &&
no match
} +
+
+ {(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`} +
+
+ ); +} + +export function AlertsModal({ onClose, bands, modes, countries }: { + onClose: () => void; bands: string[]; modes: string[]; countries: string[]; +}) { + const [rules, setRules] = useState([]); + const [draft, setDraft] = useState(null); + const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select) + const [emailTo, setEmailTo] = useState(''); + const [err, setErr] = useState(''); + + const refresh = useCallback(async () => { + try { setRules(((await ListAlertRules()) ?? []) as Rule[]); } + catch (e: any) { setErr(String(e?.message ?? e)); } + }, []); + useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]); + + const patch = (p: Partial) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d)); + const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => { + if (!d) return d; + const cur = ((d as any)[key] as string[]) ?? []; + const has = cur.some((x) => x.toLowerCase() === v.toLowerCase()); + const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v]; + return alerts.Rule.createFrom({ ...d, [key]: next }); + }); + + async function save() { + if (!draft) return; + if (!draft.name.trim()) { setErr('Give the rule a name'); return; } + try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); } + catch (e: any) { setErr(String(e?.message ?? e)); } + } + async function del() { + if (!draft) return; + if (!draft.id) { setDraft(null); return; } + if (!window.confirm(`Delete alert "${draft.name}"?`)) return; + try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); } + catch (e: any) { setErr(String(e?.message ?? e)); } + } + + return ( + { if (!o) onClose(); }}> + + + Alert management + Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m). + + +
+ {/* Rule list */} +
+
+ Rules + +
+
+ {rules.length === 0 &&
No rules yet — click +
} + {rules.map((r) => ( + + ))} +
+
+ + setEmailTo(e.target.value)} + onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} /> +
+
+ + {/* Editor */} +
+ {!draft ? ( +
Select or create a rule.
+ ) : ( + +
+ + Definition + Call / DXCC + Band / Mode + Origin + +
+ +
+ +
+ + patch({ name: e.target.value })} /> +
+ +
+ + patch({ again_after_min: parseInt(e.target.value) || 0 })} /> + 0 = once/session · -1 = always +
+
+ +
+ + + +
+
+ +
+ + +
+ +