package monitor import ( "bufio" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "regexp" "strconv" "strings" "sync" "time" "nano-router/config" "nano-router/mihomo" ) const ( checkInterval = 30 * time.Second speedInterval = 5 * time.Second ipCheckInterval = 10 * time.Minute minuteSlots = 60 ) type MinuteSlot struct { Minute int64 `json:"minute"` Status string `json:"status"` Pings map[string]int `json:"pings,omitempty"` PingCF int `json:"ping_cf,omitempty"` PingGG int `json:"ping_gg,omitempty"` } type ConnectivityState struct { DirectUp bool `json:"direct_up"` VPNUp bool `json:"vpn_up"` DirectUpSince string `json:"direct_up_since,omitempty"` VPNUpSince string `json:"vpn_up_since,omitempty"` MinutesD []MinuteSlot `json:"minutes_direct"` MinutesV []MinuteSlot `json:"minutes_vpn"` EndpointNames []string `json:"endpoint_names"` } type Sample struct { Time time.Time `json:"time"` RxPS float64 `json:"rx_bps"` TxPS float64 `json:"tx_bps"` } type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` CC string `json:"cc"` } type SystemStats struct { CPUPct float64 `json:"cpu_pct"` MemPct float64 `json:"mem_pct"` MemTotal uint64 `json:"mem_total"` MemUsed uint64 `json:"mem_used"` MemFree uint64 `json:"mem_free"` Uptime int64 `json:"uptime"` } type DashboardData struct { System SystemStats `json:"system"` Mihomo MihomoStatus `json:"mihomo"` Connectivity ConnectivityState `json:"connectivity"` GatewayIface string `json:"gateway_iface"` TrafficAvg []Sample `json:"traffic_avg"` TrafficReal []Sample `json:"traffic_real"` IPDirect *IPInfo `json:"ip_direct"` IPVPN *IPInfo `json:"ip_vpn"` } type MihomoStatus struct { Running bool `json:"running"` PID int `json:"pid,omitempty"` } var ( mu sync.RWMutex connState ConnectivityState minuteSlotsD [minuteSlots]MinuteSlot minuteSlotsV [minuteSlots]MinuteSlot gatewayIface string prevRx uint64 prevTx uint64 prevSpeedTime time.Time curRxPS float64 curTxPS float64 avgSamples []Sample realSamples []Sample ipDirect *IPInfo ipVPN *IPInfo ipDirectT time.Time ipVPNT time.Time directUpSince *time.Time vpnUpSince *time.Time wasDirectUp bool wasVPNUp bool prevCPUTotal uint64 prevCPUIdle uint64 cpuInitialized bool stopCh chan struct{} ) func Start() { stopCh = make(chan struct{}) gatewayIface = detectGatewayIface() for i := 0; i < minuteSlots; i++ { minuteSlotsD[i] = MinuteSlot{Status: "no_data"} minuteSlotsV[i] = MinuteSlot{Status: "no_data"} } go connectivityLoop() go speedLoop() go ipCheckLoop() } func Stop() { close(stopCh) } func GetData() DashboardData { mu.RLock() defer mu.RUnlock() cfg, _ := config.Load() var epNames []string if cfg != nil && len(cfg.Connectivity.Direct) > 0 { for _, ep := range cfg.Connectivity.Direct { epNames = append(epNames, ep.Name) } } else { epNames = []string{"Cloudflare", "Google"} } connState.EndpointNames = epNames d := DashboardData{ System: readSystemStats(), Mihomo: getMihomoRunning(), Connectivity: connState, GatewayIface: gatewayIface, IPDirect: ipDirect, IPVPN: ipVPN, } d.Connectivity.MinutesD = buildMinuteSlots(minuteSlotsD[:]) d.Connectivity.MinutesV = buildMinuteSlots(minuteSlotsV[:]) now := time.Now() cutoff10m := now.Add(-10 * time.Minute) cutoff1m := now.Add(-1 * time.Minute) filteredAvg := make([]Sample, 0, len(avgSamples)) for _, s := range avgSamples { if !s.Time.Before(cutoff10m) { filteredAvg = append(filteredAvg, s) } } d.TrafficAvg = filteredAvg filteredReal := make([]Sample, 0, len(realSamples)) for _, s := range realSamples { if !s.Time.Before(cutoff1m) { filteredReal = append(filteredReal, s) } } d.TrafficReal = filteredReal if d.IPDirect != nil && time.Since(ipDirectT) > ipCheckInterval+time.Minute { d.IPDirect = nil } if d.IPVPN != nil && time.Since(ipVPNT) > ipCheckInterval+time.Minute { d.IPVPN = nil } return d } func getMihomoRunning() MihomoStatus { st := mihomo.Status() r, _ := st["running"].(bool) s := MihomoStatus{Running: r} if r { if pid, ok := st["pid"].(int); ok { s.PID = pid } } return s } func readSystemStats() SystemStats { s := SystemStats{} readCPUStats(&s) readMemStats(&s) readUptime(&s) return s } func readCPUStats(s *SystemStats) { f, err := os.Open("/proc/stat") if err != nil { return } defer f.Close() scanner := bufio.NewScanner(f) if !scanner.Scan() { return } line := scanner.Text() if !strings.HasPrefix(line, "cpu ") { return } fields := strings.Fields(line) if len(fields) < 5 { return } var total, idle uint64 for i := 1; i < len(fields) && i <= 8; i++ { v, _ := strconv.ParseUint(fields[i], 10, 64) total += v if i == 4 { idle = v } } if cpuInitialized && prevCPUTotal > 0 { dTotal := total - prevCPUTotal dIdle := idle - prevCPUIdle if dTotal > 0 { s.CPUPct = float64(dTotal-dIdle) / float64(dTotal) * 100.0 } } prevCPUTotal = total prevCPUIdle = idle cpuInitialized = true } func readMemStats(s *SystemStats) { f, err := os.Open("/proc/meminfo") if err != nil { return } defer f.Close() var memTotal, memFree, memAvailable, buffers, cached uint64 scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() parts := strings.Fields(line) if len(parts) < 2 { continue } val, _ := strconv.ParseUint(parts[1], 10, 64) switch { case strings.HasPrefix(parts[0], "MemTotal:"): memTotal = val case strings.HasPrefix(parts[0], "MemFree:"): memFree = val case strings.HasPrefix(parts[0], "MemAvailable:"): memAvailable = val case strings.HasPrefix(parts[0], "Buffers:"): buffers = val case strings.HasPrefix(parts[0], "Cached:"): cached = val } } s.MemTotal = memTotal * 1024 s.MemFree = memFree * 1024 used := memTotal - memAvailable if memAvailable == 0 { used = memTotal - memFree - buffers - cached } s.MemUsed = used * 1024 if memTotal > 0 { s.MemPct = float64(used) / float64(memTotal) * 100.0 } } func readUptime(s *SystemStats) { f, err := os.Open("/proc/uptime") if err != nil { return } defer f.Close() var upSec float64 fmt.Fscanf(f, "%f", &upSec) s.Uptime = int64(upSec) } func detectGatewayIface() string { out, err := exec.Command("ip", "route", "show", "default").Output() if err != nil { return "" } for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) for i, f := range fields { if f == "dev" && i+1 < len(fields) { return fields[i+1] } } } return "" } func readIfaceBytes(iface string) (rx, tx uint64) { f, err := os.Open("/proc/net/dev") if err != nil { return 0, 0 } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() colon := strings.Index(line, ":") if colon < 0 { continue } name := strings.TrimSpace(line[:colon]) if name != iface { continue } fields := strings.Fields(line[colon+1:]) if len(fields) >= 10 { rx, _ = strconv.ParseUint(fields[0], 10, 64) tx, _ = strconv.ParseUint(fields[8], 10, 64) } return } return 0, 0 } func connectivityLoop() { ticker := time.NewTicker(checkInterval) defer ticker.Stop() checkConnectivity() for { select { case <-ticker.C: checkConnectivity() case <-stopCh: return } } } func checkConnectivity() { directResult := checkDirectConnectivity() vpnResult := checkVPNConnectivity() now := time.Now() minuteIdx := int(now.Unix()/60) % minuteSlots mu.Lock() defer mu.Unlock() connState.DirectUp = directResult.Up connState.VPNUp = vpnResult.Up if directResult.Up && !wasDirectUp { directUpSince = &time.Time{} *directUpSince = now } else if !directResult.Up { directUpSince = nil } wasDirectUp = directResult.Up mihomoRunning := mihomo.IsRunning() if vpnResult.Up && !wasVPNUp { vpnUpSince = &time.Time{} *vpnUpSince = now } else if !vpnResult.Up { vpnUpSince = nil } wasVPNUp = vpnResult.Up if directUpSince != nil { dur := now.Sub(*directUpSince) connState.DirectUpSince = fmtDuration(dur) } else { connState.DirectUpSince = "" } if vpnUpSince != nil { dur := now.Sub(*vpnUpSince) connState.VPNUpSince = fmtDuration(dur) } else { connState.VPNUpSince = "" } statusD := "up" if !directResult.Up { statusD = "down" } statusV := "up" if !vpnResult.Up { statusV = "down" } if !mihomoRunning { statusV = "no_data" } dSlot := MinuteSlot{Status: statusD, Pings: directResult.Pings} if v, ok := directResult.Pings["Cloudflare"]; ok { dSlot.PingCF = v } if v, ok := directResult.Pings["Google"]; ok { dSlot.PingGG = v } minuteSlotsD[minuteIdx] = mergeSlot(minuteSlotsD[minuteIdx], dSlot) vSlot := MinuteSlot{Status: statusV, Pings: make(map[string]int)} if vpnResult.Up { vSlot.Pings = vpnResult.Pings if v, ok := vpnResult.Pings["Cloudflare"]; ok { vSlot.PingCF = v } if v, ok := vpnResult.Pings["Google"]; ok { vSlot.PingGG = v } } minuteSlotsV[minuteIdx] = mergeSlot(minuteSlotsV[minuteIdx], vSlot) } func fmtDuration(d time.Duration) string { s := int(d.Seconds()) if s < 0 { s = 0 } days := s / 86400 s %= 86400 hours := s / 3600 s %= 3600 mins := s / 60 secs := s % 60 parts := []string{} if days > 0 { parts = append(parts, fmt.Sprintf("%dd", days)) } if hours > 0 || days > 0 { parts = append(parts, fmt.Sprintf("%dh", hours)) } if mins > 0 || hours > 0 || days > 0 { parts = append(parts, fmt.Sprintf("%dm", mins)) } parts = append(parts, fmt.Sprintf("%ds", secs)) return strings.Join(parts, " ") } func mergeSlot(existing, new MinuteSlot) MinuteSlot { merged := MinuteSlot{ Status: new.Status, Pings: make(map[string]int), } if new.Status == "down" && existing.Status == "up" { merged.Status = "down" } else if new.Status == "up" && existing.Status == "down" { merged.Status = "down" } for k, v := range existing.Pings { merged.Pings[k] = v } for k, v := range new.Pings { if v > 0 { merged.Pings[k] = v } } if v, ok := merged.Pings["Cloudflare"]; ok { merged.PingCF = v } if v, ok := merged.Pings["Google"]; ok { merged.PingGG = v } return merged } type connResult struct { Up bool Pings map[string]int PingCF int PingGG int } func checkDirectConnectivity() connResult { result := connResult{Pings: make(map[string]int)} client := &http.Client{Timeout: 8 * time.Second} cfg, _ := config.Load() if cfg == nil || len(cfg.Connectivity.Direct) == 0 { def := config.DefaultConnectivity() cfg = &config.AppConfig{Connectivity: def} } endpoints := cfg.Connectivity.Direct anyUp := false for _, ep := range endpoints { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) if err != nil { cancel() continue } resp, err := client.Do(req) elapsed := time.Since(start) cancel() if err != nil { continue } resp.Body.Close() ms := int(elapsed.Milliseconds()) if resp.StatusCode >= 200 && resp.StatusCode < 400 { anyUp = true result.Pings[ep.Name] = ms } } result.Up = anyUp return result } func checkVPNConnectivity() connResult { result := connResult{Pings: make(map[string]int)} if !mihomo.IsRunning() { return result } mixedPort := getMihomoMixedPort() if mixedPort == 0 { mixedPort = 7890 } proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort) proxyURL, _ := url.Parse("http://" + proxyAddr) proxyClient := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), }, } cfg, _ := config.Load() if cfg == nil || len(cfg.Connectivity.ViaProxy) == 0 { def := config.DefaultConnectivity() cfg = &config.AppConfig{Connectivity: def} } endpoints := cfg.Connectivity.ViaProxy anyUp := false for _, ep := range endpoints { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) if err != nil { cancel() continue } resp, err := proxyClient.Do(req) elapsed := time.Since(start) cancel() if err != nil { continue } resp.Body.Close() ms := int(elapsed.Milliseconds()) if resp.StatusCode >= 200 && resp.StatusCode < 400 { anyUp = true result.Pings[ep.Name] = ms } } result.Up = anyUp return result } func getMihomoMixedPort() int { cfg, err := mihomo.LoadConfig() if err != nil { return 7890 } if mp, ok := cfg["mixed-port"]; ok { switch v := mp.(type) { case int: return v case float64: return int(v) } } return 7890 } func speedLoop() { iface := gatewayIface if iface == "" { iface = detectGatewayIface() gatewayIface = iface } if iface == "" { return } rx, tx := readIfaceBytes(iface) prevRx = rx prevTx = tx prevSpeedTime = time.Now() ticker := time.NewTicker(speedInterval) defer ticker.Stop() for { select { case <-ticker.C: captureSpeedSample() case <-stopCh: return } } } func captureSpeedSample() { iface := gatewayIface if iface == "" { iface = detectGatewayIface() if iface == "" { return } mu.Lock() gatewayIface = iface mu.Unlock() } rx, tx := readIfaceBytes(iface) now := time.Now() mu.Lock() defer mu.Unlock() dt := now.Sub(prevSpeedTime).Seconds() if dt > 0 && prevSpeedTime.Before(now) { curRxPS = float64(rx-prevRx) * 8.0 / dt curTxPS = float64(tx-prevTx) * 8.0 / dt } prevRx = rx prevTx = tx prevSpeedTime = now sample := Sample{Time: now, RxPS: curRxPS, TxPS: curTxPS} realSamples = append(realSamples, sample) cutoff1m := now.Add(-1 * time.Minute) filtered := make([]Sample, 0, len(realSamples)) for _, s := range realSamples { if !s.Time.Before(cutoff1m) { filtered = append(filtered, s) } } realSamples = filtered avgSamples = append(avgSamples, sample) cutoff10m := now.Add(-10 * time.Minute) filteredAvg := make([]Sample, 0, len(avgSamples)) for _, s := range avgSamples { if !s.Time.Before(cutoff10m) { filteredAvg = append(filteredAvg, s) } } avgSamples = filteredAvg } func ipCheckLoop() { checkIPs() ticker := time.NewTicker(ipCheckInterval) defer ticker.Stop() for { select { case <-ticker.C: checkIPs() case <-stopCh: return } } } func checkIPs() { directInfo := fetchIPDirect() if directInfo != nil { mu.Lock() ipDirect = directInfo ipDirectT = time.Now() mu.Unlock() } if mihomo.IsRunning() { vpnInfo := fetchIPVPN() if vpnInfo != nil { mu.Lock() ipVPN = vpnInfo ipVPNT = time.Now() mu.Unlock() } } } func fetchIPDirect() *IPInfo { client := &http.Client{Timeout: 10 * time.Second} return fetchIPFromServices(client) } func fetchIPVPN() *IPInfo { mixedPort := getMihomoMixedPort() proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort) proxyURL, _ := url.Parse("http://" + proxyAddr) client := &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), }, } return fetchIPFromServices(client) } func fetchIPFromServices(client *http.Client) *IPInfo { services := []struct { url string parse func(body []byte) *IPInfo }{ { "https://ipinfo.io/json", func(body []byte) *IPInfo { var data struct { IP string `json:"ip"` Country string `json:"country"` } if err := json.Unmarshal(body, &data); err != nil || data.IP == "" { return nil } return &IPInfo{IP: data.IP, Country: data.Country, CC: strings.ToLower(data.Country)} }, }, { "https://ipapi.co/json/", func(body []byte) *IPInfo { var data struct { IP string `json:"ip"` CountryCode string `json:"country_code"` CountryName string `json:"country_name"` } if err := json.Unmarshal(body, &data); err != nil || data.IP == "" { return nil } cc := strings.ToLower(data.CountryCode) country := data.CountryName if country == "" { country = data.CountryCode } return &IPInfo{IP: data.IP, Country: country, CC: cc} }, }, } for _, svc := range services { resp, err := client.Get(svc.url) if err != nil { continue } body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() if err != nil { continue } info := svc.parse(body) if info != nil { return info } } return nil } func buildMinuteSlots(slots []MinuteSlot) []MinuteSlot { result := make([]MinuteSlot, minuteSlots) now := time.Now() currentMinute := now.Unix() / 60 for i := 0; i < minuteSlots; i++ { minuteOffset := minuteSlots - 1 - i minuteIdx := int((currentMinute - int64(minuteOffset)) % minuteSlots) if minuteIdx < 0 { minuteIdx += minuteSlots } result[i] = slots[minuteIdx] result[i].Minute = currentMinute - int64(minuteOffset) if result[i].Status == "" { result[i].Status = "no_data" } } return result } var countryFlagRe = regexp.MustCompile(`^[a-zA-Z]{2}$`) func FlagEmoji(cc string) string { if !countryFlagRe.MatchString(cc) { return "" } cc = strings.ToUpper(cc) runes := []rune(cc) if len(runes) != 2 { return "" } base := rune(0x1F1E6) return string(base+(runes[0]-'A')) + string(base+(runes[1]-'A')) }