feat: Cluster alert implemented

This commit is contained in:
2026-07-03 19:08:50 +02:00
parent 8740a4ba66
commit 3e199f9ab6
7 changed files with 823 additions and 0 deletions
+38
View File
@@ -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 && (
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
)}
<AutoEQSL
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
+256
View File
@@ -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 (
<div className="rounded-md border border-border">
{searchable && (
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
<Search className="size-3 text-muted-foreground" />
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
)}
<div className={cn('overflow-y-auto p-1', height)}>
{shown.map((o) => (
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
<span className="truncate">{o}</span>
</label>
))}
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
</div>
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
</div>
</div>
);
}
export function AlertsModal({ onClose, bands, modes, countries }: {
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
}) {
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(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<Rule>) => 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 (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
</DialogHeader>
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
{/* Rule list */}
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">Rules</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">No rules yet click +</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
<span className="truncate flex-1">{r.name}</span>
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
</button>
))}
</div>
<div className="px-2 py-1.5 border-t border-border/60">
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
onChange={(e) => setEmailTo(e.target.value)}
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
</div>
</div>
{/* Editor */}
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
{!draft ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
) : (
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-2 pt-2">
<TabsList>
<TabsTrigger value="def">Definition</TabsTrigger>
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
<TabsTrigger value="orig">Origin</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<TabsContent value="def" className="mt-0 space-y-3">
<div>
<Label className="text-xs">Rule name</Label>
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
</label>
<div className="flex items-center gap-2">
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-xs font-semibold">Actions</Label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> Visual</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> Sound</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> E-mail</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> Skip calls already worked (same band + mode)
</label>
</TabsContent>
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Countries (DXCC)</Label>
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
</label>
))}
</div>
</div>
</TabsContent>
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Bands</Label>
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Modes</Label>
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
</div>
</TabsContent>
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Spotter callsign (wildcard)</Label>
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
onChange={(e) => patch({ spotter_call: e.target.value })} />
<Label className="text-xs mt-2 block">Spotter continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
</label>
))}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Spotter countries</Label>
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
</div>
</TabsContent>
</div>
{/* Editor actions */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
<Button size="sm" onClick={save}>Save rule</Button>
</div>
</Tabs>
)}
</div>
</div>
<div className="flex justify-end px-5 pb-4">
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
</div>
</DialogContent>
</Dialog>
);
}