Skip to content

Commit 5cf01a0

Browse files
chore: merge dev into master for v1.1.0 release
- Add platform-specific connection info (WiFi/ethernet metrics per measurement) - Expand DB schema with 8 new connection columns - Complete UI rewrite: Vue 3 + Vite replacing vanilla HTML/JS - Add settings modal for internet probe configuration - Simplify web server to use http.FileServer on Vite dist
2 parents 3d44b7c + ea26f27 commit 5cf01a0

39 files changed

Lines changed: 4362 additions & 498 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ go.work.sum
3333
.env
3434
.env.*
3535

36+
# Frontend
37+
web/node_modules/
38+
web/dist/
39+
3640
# macOS
3741
.DS_Store
3842

ACKNOWLEDGEMENTS.md

Lines changed: 0 additions & 11 deletions
This file was deleted.

Makefile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ BINARY := netmon
22
CMD := ./cmd/netmon
33
IMAGE := netmon
44

5-
.PHONY: build run clean vet docker docker-run
5+
.PHONY: build ui run clean vet docker docker-run
66

7-
build:
7+
ui:
8+
cd web && npm install && npm run build
9+
10+
build: ui
811
CGO_ENABLED=0 go build -o $(BINARY) $(CMD)
912

10-
run:
13+
run: ui
1114
go run $(CMD)
1215

1316
clean:
1417
rm -f $(BINARY) netmon.db netmon.db-shm netmon.db-wal
18+
rm -rf web/dist web/node_modules
1519

1620
vet:
1721
go vet ./...

cmd/netmon/main.go

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"io/fs"
56
"log/slog"
67
"net/http"
78
"os"
@@ -43,37 +44,17 @@ func main() {
4344

4445
h := server.NewHandler(s, mon)
4546

47+
distFS, err := fs.Sub(web.FS, "dist")
48+
if err != nil {
49+
log.Error("failed to sub web FS", "error", err)
50+
os.Exit(1)
51+
}
52+
4653
mux := http.NewServeMux()
4754
mux.HandleFunc("GET /api/data", h.GetData)
4855
mux.HandleFunc("GET /api/config", h.GetConfig)
4956
mux.HandleFunc("POST /api/config", h.SaveConfig)
50-
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
51-
data, err := web.FS.ReadFile("index.html")
52-
if err != nil {
53-
http.Error(w, err.Error(), http.StatusInternalServerError)
54-
return
55-
}
56-
w.Header().Set("Content-Type", "text/html")
57-
w.Write(data)
58-
})
59-
mux.HandleFunc("GET /style.css", func(w http.ResponseWriter, r *http.Request) {
60-
data, err := web.FS.ReadFile("style.css")
61-
if err != nil {
62-
http.Error(w, err.Error(), http.StatusInternalServerError)
63-
return
64-
}
65-
w.Header().Set("Content-Type", "text/css")
66-
w.Write(data)
67-
})
68-
mux.HandleFunc("GET /script.js", func(w http.ResponseWriter, r *http.Request) {
69-
data, err := web.FS.ReadFile("script.js")
70-
if err != nil {
71-
http.Error(w, err.Error(), http.StatusInternalServerError)
72-
return
73-
}
74-
w.Header().Set("Content-Type", "application/javascript")
75-
w.Write(data)
76-
})
57+
mux.Handle("/", http.FileServer(http.FS(distFS)))
7758

7859
log.Info("starting server", "addr", ":8080")
7960
if err := http.ListenAndServe(":8080", mux); err != nil {

config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"ping_targets": ["google.com", "cloudflare.com"],
33
"dns_targets": ["google.com", "cloudflare.com"],
44
"ping_interval_s": 60,
5-
"speed_interval_m": 30,
5+
"speed_interval_m": 5,
66
"ping_count": 5,
77
"download_url": "https://speed.cloudflare.com/__down?bytes=1000000",
88
"upload_url": "https://speed.cloudflare.com/__up"

internal/monitor/monitor.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package monitor
22

33
import (
44
"context"
5+
"fmt"
56
"log/slog"
67
"math"
78
"net"
@@ -136,6 +137,7 @@ func (m *Monitor) speedWorker(ctx context.Context) {
136137
}
137138

138139
func (m *Monitor) runPingCycle() {
140+
fmt.Println()
139141
m.log.Info("ping cycle: start")
140142

141143
cfg := m.GetConfig() // snapshot config for this cycle
@@ -165,6 +167,11 @@ func (m *Monitor) runPingCycle() {
165167

166168
results := make([]pingResult, len(allTargets))
167169
var wg sync.WaitGroup
170+
var connInfo network.ConnectionInfo
171+
wg.Go(func() {
172+
connInfo, _ = network.GetConnectionInfo()
173+
})
174+
168175
for i, target := range allTargets {
169176
wg.Add(1)
170177
go func(idx int, t string) {
@@ -248,14 +255,22 @@ func (m *Monitor) runPingCycle() {
248255
m.mu.RUnlock()
249256

250257
meas := store.Measurement{
251-
Time: time.Now().Format(time.RFC3339),
252-
NetworkID: m.currentNetworkID,
253-
Latency: round1(avgLat),
254-
Jitter: round1(avgJitter),
255-
PacketLoss: round1(avgLoss),
256-
Download: round1(down),
257-
Upload: round1(up),
258-
DNS: round1(dnsAvg),
258+
Time: time.Now().Format(time.RFC3339),
259+
NetworkID: m.currentNetworkID,
260+
Latency: round1(avgLat),
261+
Jitter: round1(avgJitter),
262+
PacketLoss: round1(avgLoss),
263+
Download: round1(down),
264+
Upload: round1(up),
265+
DNS: round1(dnsAvg),
266+
ConnType: connInfo.Type,
267+
ConnRSSI: connInfo.RSSI,
268+
ConnNoise: connInfo.Noise,
269+
ConnSNR: connInfo.SNR,
270+
ConnChannel: connInfo.Channel,
271+
ConnBand: connInfo.Band,
272+
ConnLinkRate: connInfo.LinkRate,
273+
ConnDuplex: connInfo.Duplex,
259274
}
260275

261276
if err := m.store.SaveMeasurement(meas); err != nil {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//go:build darwin
2+
3+
package network
4+
5+
import (
6+
"encoding/json"
7+
"os/exec"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// ConnectionInfo describes the active network connection regardless of type.
13+
type ConnectionInfo struct {
14+
Type string // "wifi", "ethernet", or ""
15+
// WiFi-specific
16+
RSSI int // signal strength dBm (negative; 0 = unavailable)
17+
Noise int // noise floor dBm (negative; 0 = unavailable)
18+
SNR int // signal-to-noise ratio dB (0 = unavailable)
19+
Channel int // WiFi channel number (0 = unavailable)
20+
Band string // "2GHz", "5GHz", "6GHz", or ""
21+
// Shared
22+
LinkRate int // link rate / speed Mbps (0 = unavailable)
23+
// Ethernet-specific
24+
Duplex string // "full", "half", or ""
25+
}
26+
27+
// GetConnectionInfo detects the active interface and returns its connection details.
28+
// It reuses darwinRoute() (defined in network.go) to avoid running route twice.
29+
func GetConnectionInfo() (ConnectionInfo, bool) {
30+
_, activeIface := darwinRoute()
31+
32+
// Try WiFi via system_profiler
33+
if spOut, err := exec.Command("system_profiler", "SPAirPortDataType", "-json").Output(); err == nil {
34+
if info, ok := parseMacWifi(spOut, activeIface); ok {
35+
return info, true
36+
}
37+
}
38+
39+
// Fall back to Ethernet
40+
if activeIface != "" {
41+
return macEthernetInfo(activeIface)
42+
}
43+
return ConnectionInfo{}, false
44+
}
45+
46+
// spAirPortData is the relevant subset of system_profiler SPAirPortDataType JSON.
47+
type spAirPortData struct {
48+
SPAirPortDataType []struct {
49+
Interfaces []struct {
50+
Name string `json:"_name"`
51+
Current *struct {
52+
Channel string `json:"spairport_network_channel"`
53+
Rate int `json:"spairport_network_rate"`
54+
SignalNoise string `json:"spairport_signal_noise"`
55+
} `json:"spairport_current_network_information"`
56+
} `json:"spairport_airport_interfaces"`
57+
} `json:"SPAirPortDataType"`
58+
}
59+
60+
// parseMacWifi extracts WiFi stats from system_profiler JSON.
61+
// If activeIface is non-empty, only the matching interface is considered.
62+
func parseMacWifi(data []byte, activeIface string) (ConnectionInfo, bool) {
63+
var sp spAirPortData
64+
if err := json.Unmarshal(data, &sp); err != nil {
65+
return ConnectionInfo{}, false
66+
}
67+
for _, section := range sp.SPAirPortDataType {
68+
for _, iface := range section.Interfaces {
69+
if activeIface != "" && iface.Name != activeIface {
70+
continue
71+
}
72+
cur := iface.Current
73+
if cur == nil || cur.SignalNoise == "" {
74+
continue
75+
}
76+
var info ConnectionInfo
77+
info.Type = "wifi"
78+
79+
// Parse "-24 dBm / -97 dBm"
80+
parts := strings.SplitN(cur.SignalNoise, " / ", 2)
81+
if len(parts) == 2 {
82+
rssi, e1 := strconv.Atoi(strings.TrimSuffix(parts[0], " dBm"))
83+
noise, e2 := strconv.Atoi(strings.TrimSuffix(parts[1], " dBm"))
84+
if e1 == nil && e2 == nil {
85+
info.RSSI = rssi
86+
info.Noise = noise
87+
info.SNR = rssi - noise
88+
}
89+
}
90+
91+
// Parse "1 (2GHz, 20MHz)"
92+
if chParts := strings.SplitN(cur.Channel, " ", 2); len(chParts) >= 1 {
93+
info.Channel, _ = strconv.Atoi(chParts[0])
94+
}
95+
switch {
96+
case strings.Contains(cur.Channel, "6GHz"):
97+
info.Band = "6GHz"
98+
case strings.Contains(cur.Channel, "5GHz"):
99+
info.Band = "5GHz"
100+
case strings.Contains(cur.Channel, "2GHz"):
101+
info.Band = "2GHz"
102+
}
103+
info.LinkRate = cur.Rate
104+
return info, info.RSSI != 0
105+
}
106+
}
107+
return ConnectionInfo{}, false
108+
}
109+
110+
// macEthernetInfo reads the link speed and duplex from ifconfig for a wired interface.
111+
func macEthernetInfo(iface string) (ConnectionInfo, bool) {
112+
out, err := exec.Command("ifconfig", iface).Output()
113+
if err != nil {
114+
return ConnectionInfo{}, false
115+
}
116+
info := parseIfconfigMedia(string(out))
117+
if info.LinkRate == 0 {
118+
return ConnectionInfo{}, false
119+
}
120+
info.Type = "ethernet"
121+
return info, true
122+
}
123+
124+
// parseIfconfigMedia parses "media: autoselect (1000baseT <full-duplex>)" lines.
125+
func parseIfconfigMedia(output string) ConnectionInfo {
126+
var info ConnectionInfo
127+
for _, line := range strings.Split(output, "\n") {
128+
line = strings.TrimSpace(line)
129+
if !strings.HasPrefix(line, "media:") {
130+
continue
131+
}
132+
start := strings.Index(line, "(")
133+
end := strings.LastIndex(line, ")")
134+
if start < 0 || end <= start {
135+
break
136+
}
137+
inner := line[start+1 : end] // e.g. "1000baseT <full-duplex>"
138+
fields := strings.Fields(inner)
139+
if len(fields) == 0 {
140+
break
141+
}
142+
// Extract leading digits from "1000baseT" → 1000
143+
speedStr := fields[0]
144+
nonDigit := strings.IndexFunc(speedStr, func(r rune) bool { return r < '0' || r > '9' })
145+
if nonDigit > 0 {
146+
info.LinkRate, _ = strconv.Atoi(speedStr[:nonDigit])
147+
}
148+
for _, f := range fields[1:] {
149+
switch f {
150+
case "<full-duplex>":
151+
info.Duplex = "full"
152+
case "<half-duplex>":
153+
info.Duplex = "half"
154+
}
155+
}
156+
break
157+
}
158+
return info
159+
}

0 commit comments

Comments
 (0)