Skip to content

Commit 3742ba8

Browse files
dbfxclaude
andcommitted
feat: add visual traceroute map and alerts & notifications
Visual Traceroute: - New full-screen map view showing packet route across the globe - GeoIP service extended to return lat/lon coordinates from ip-api.com - Animated route lines between geo-located hops, color-coded by loss - Pulsing markers for source/destination, hover tooltips with stats - Auto-centers and auto-scales map to fit the discovered route - Skips LAN/private IPs and unresponsive hops - Idle state shows clickable global AWS preset targets on the map - Floating target picker panel for quick trace launch Alerts & Notifications: - Configurable alert rules with name, source, metric, and threshold - Supports traceroute and loss monitor as data sources - Monitors latency, packet loss, and jitter metrics - Fires native Windows desktop notifications when thresholds exceeded - 30-second cooldown per rule to prevent notification spam - Rules persisted to disk (alert-rules.json in userData) - Enable/disable toggle and delete for each rule - Recent alerts log showing last 50 fired alerts with timestamps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6bc138 commit 3742ba8

File tree

13 files changed

+990
-24
lines changed

13 files changed

+990
-24
lines changed

src/main/ipc-handlers.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { ipcMain, BrowserWindow } from 'electron';
2-
import { MtrSessionConfig } from '../shared/types';
1+
import { ipcMain, BrowserWindow, Notification, app } from 'electron';
2+
import path from 'node:path';
3+
import fs from 'node:fs';
4+
import { MtrSessionConfig, AlertRule } from '../shared/types';
35
import { MtrSession } from './services/mtr-session';
46
import { LossMonitor } from './services/loss-monitor';
57
import { LOSS_MONITOR_TARGETS } from '../shared/presets';
@@ -87,6 +89,28 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void {
8789
lossMonitor = null;
8890
}
8991
});
92+
93+
// Alert rules persistence
94+
const alertsFile = path.join(app.getPath('userData'), 'alert-rules.json');
95+
96+
ipcMain.handle('alerts:load', async () => {
97+
try {
98+
const data = fs.readFileSync(alertsFile, 'utf-8');
99+
return JSON.parse(data) as AlertRule[];
100+
} catch {
101+
return [];
102+
}
103+
});
104+
105+
ipcMain.handle('alerts:save', async (_, rules: AlertRule[]) => {
106+
fs.writeFileSync(alertsFile, JSON.stringify(rules, null, 2), 'utf-8');
107+
});
108+
109+
ipcMain.handle('alerts:notify', async (_, title: string, body: string) => {
110+
if (Notification.isSupported()) {
111+
new Notification({ title, body }).show();
112+
}
113+
});
90114
}
91115

92116
export function cleanupSession(): void {

src/main/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,9 @@ contextBridge.exposeInMainWorld('mtrApi', {
6262
ipcRenderer.on('updater:error', listener as (...args: unknown[]) => void);
6363
return () => { ipcRenderer.removeListener('updater:error', listener as (...args: unknown[]) => void); };
6464
},
65+
66+
// Alerts
67+
loadAlertRules: () => ipcRenderer.invoke('alerts:load'),
68+
saveAlertRules: (rules: unknown) => ipcRenderer.invoke('alerts:save', rules),
69+
showNotification: (title: string, body: string) => ipcRenderer.invoke('alerts:notify', title, body),
6570
});

src/main/services/geoip.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import http from 'node:http';
22

3-
const cache = new Map<string, string>();
3+
export interface GeoIpResult {
4+
location: string;
5+
lat: number | null;
6+
lon: number | null;
7+
}
8+
9+
const cache = new Map<string, GeoIpResult>();
410

511
function isPrivateIp(ip: string): boolean {
612
return (
@@ -14,38 +20,50 @@ function isPrivateIp(ip: string): boolean {
1420
);
1521
}
1622

23+
const LAN_RESULT: GeoIpResult = { location: 'LAN', lat: null, lon: null };
24+
const EMPTY_RESULT: GeoIpResult = { location: '', lat: null, lon: null };
25+
1726
export function lookupGeoIp(ip: string): Promise<string> {
27+
return lookupGeoIpFull(ip).then((r) => r.location);
28+
}
29+
30+
export function lookupGeoIpFull(ip: string): Promise<GeoIpResult> {
1831
if (cache.has(ip)) return Promise.resolve(cache.get(ip)!);
1932
if (isPrivateIp(ip)) {
20-
cache.set(ip, 'LAN');
21-
return Promise.resolve('LAN');
33+
cache.set(ip, LAN_RESULT);
34+
return Promise.resolve(LAN_RESULT);
2235
}
2336

2437
return new Promise((resolve) => {
2538
const timeout = setTimeout(() => {
26-
resolve('');
39+
resolve(EMPTY_RESULT);
2740
}, 3000);
2841

2942
// ip-api.com free tier — no API key needed, 45 req/min
30-
const req = http.get(`http://ip-api.com/json/${ip}?fields=country,city`, (res) => {
43+
const req = http.get(`http://ip-api.com/json/${ip}?fields=country,city,lat,lon`, (res) => {
3144
let data = '';
3245
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
3346
res.on('end', () => {
3447
clearTimeout(timeout);
3548
try {
3649
const json = JSON.parse(data);
3750
const location = [json.city, json.country].filter(Boolean).join(', ') || '';
38-
cache.set(ip, location);
39-
resolve(location);
51+
const result: GeoIpResult = {
52+
location,
53+
lat: typeof json.lat === 'number' ? json.lat : null,
54+
lon: typeof json.lon === 'number' ? json.lon : null,
55+
};
56+
cache.set(ip, result);
57+
resolve(result);
4058
} catch {
41-
resolve('');
59+
resolve(EMPTY_RESULT);
4260
}
4361
});
4462
});
4563

4664
req.on('error', () => {
4765
clearTimeout(timeout);
48-
resolve('');
66+
resolve(EMPTY_RESULT);
4967
});
5068
});
5169
}

src/main/services/mtr-session.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MtrSessionConfig, MtrSessionState, DiscoveredHop } from '../../shared/t
33
import { runTraceroute, resolveHostname } from './traceroute';
44
import { ping } from './ping';
55
import { StatsCalculator } from './stats';
6-
import { lookupGeoIp } from './geoip';
6+
import { lookupGeoIpFull } from './geoip';
77

88
export class MtrSession extends EventEmitter {
99
private config: MtrSessionConfig;
@@ -40,8 +40,8 @@ export class MtrSession extends EventEmitter {
4040
resolveHostname(hop.ip).then((hostname) => {
4141
this.stats.setHostname(hop.hopNumber, hostname);
4242
});
43-
lookupGeoIp(hop.ip).then((geo) => {
44-
this.stats.setGeo(hop.hopNumber, geo);
43+
lookupGeoIpFull(hop.ip).then((result) => {
44+
this.stats.setGeo(hop.hopNumber, result.location, result.lat, result.lon);
4545
});
4646
}
4747
},

src/main/services/stats.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ interface HopStats {
55
ip: string;
66
hostname: string;
77
geo: string;
8+
geoLat: number | null;
9+
geoLon: number | null;
810
sent: number;
911
received: number;
1012
last: number | null;
@@ -30,6 +32,8 @@ export class StatsCalculator {
3032
ip,
3133
hostname: ip,
3234
geo: '',
35+
geoLat: null,
36+
geoLon: null,
3337
sent: 0,
3438
received: 0,
3539
last: null,
@@ -47,9 +51,13 @@ export class StatsCalculator {
4751
if (hop) hop.hostname = hostname;
4852
}
4953

50-
setGeo(hopNumber: number, geo: string): void {
54+
setGeo(hopNumber: number, geo: string, lat?: number | null, lon?: number | null): void {
5155
const hop = this.hops.get(hopNumber);
52-
if (hop) hop.geo = geo;
56+
if (hop) {
57+
hop.geo = geo;
58+
if (lat !== undefined) hop.geoLat = lat;
59+
if (lon !== undefined) hop.geoLon = lon;
60+
}
5361
}
5462

5563
addSample(hopNumber: number, rtt: number | null): void {
@@ -96,6 +104,8 @@ export class StatsCalculator {
96104
ip: hop.ip,
97105
hostname: hop.hostname,
98106
geo: hop.geo,
107+
geoLat: hop.geoLat,
108+
geoLon: hop.geoLon,
99109
sent: hop.sent,
100110
received: hop.received,
101111
lossPercent: Math.round(lossPercent * 10) / 10,

src/renderer/App.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@ import { AnimatePresence, motion } from 'framer-motion';
33
import Layout from './components/Layout';
44
import TraceView from './components/TraceView';
55
import MapView from './components/MapView';
6+
import VisualTraceView from './components/VisualTraceView';
67
import LossMonitorView from './components/LossMonitorView';
8+
import AlertsView from './components/AlertsView';
79
import AboutView from './components/AboutView';
810
import { useMtrSession } from './hooks/useMtrSession';
911
import { useLossMonitor } from './hooks/useLossMonitor';
12+
import { useAlerts } from './hooks/useAlerts';
1013

1114
export default function App() {
12-
const [activeView, setActiveView] = useState<'trace' | 'map' | 'loss' | 'about'>('trace');
15+
const [activeView, setActiveView] = useState<'trace' | 'map' | 'visual' | 'loss' | 'alerts' | 'about'>('trace');
1316
const { hops, status, target, resolvedIp, error, start, stop } = useMtrSession();
1417
const lossMonitor = useLossMonitor();
18+
const alerts = useAlerts();
1519

1620
const handleStart = useCallback(
1721
(t: string, interval: number) => {
@@ -35,6 +39,10 @@ export default function App() {
3539
[start],
3640
);
3741

42+
// Check alerts whenever we have data
43+
alerts.checkTrace(hops);
44+
alerts.checkLoss(lossMonitor.targets);
45+
3846
return (
3947
<Layout activeView={activeView} onViewChange={setActiveView}>
4048
<AnimatePresence mode="wait">
@@ -70,6 +78,26 @@ export default function App() {
7078
<MapView onSelectTarget={handleMapSelect} />
7179
</motion.div>
7280
)}
81+
{activeView === 'visual' && (
82+
<motion.div
83+
key="visual"
84+
initial={{ opacity: 0, y: 8 }}
85+
animate={{ opacity: 1, y: 0 }}
86+
exit={{ opacity: 0, y: -8 }}
87+
transition={{ duration: 0.2 }}
88+
className="h-full"
89+
>
90+
<VisualTraceView
91+
hops={hops}
92+
status={status}
93+
target={target}
94+
resolvedIp={resolvedIp}
95+
error={error}
96+
onStart={handleStart}
97+
onStop={handleStop}
98+
/>
99+
</motion.div>
100+
)}
73101
{activeView === 'loss' && (
74102
<motion.div
75103
key="loss"
@@ -87,6 +115,24 @@ export default function App() {
87115
/>
88116
</motion.div>
89117
)}
118+
{activeView === 'alerts' && (
119+
<motion.div
120+
key="alerts"
121+
initial={{ opacity: 0, y: 8 }}
122+
animate={{ opacity: 1, y: 0 }}
123+
exit={{ opacity: 0, y: -8 }}
124+
transition={{ duration: 0.2 }}
125+
className="h-full"
126+
>
127+
<AlertsView
128+
rules={alerts.rules}
129+
recentAlerts={alerts.recentAlerts}
130+
onAddRule={alerts.addRule}
131+
onRemoveRule={alerts.removeRule}
132+
onToggleRule={alerts.toggleRule}
133+
/>
134+
</motion.div>
135+
)}
90136
{activeView === 'about' && (
91137
<motion.div
92138
key="about"

0 commit comments

Comments
 (0)