package metrics import ( "context" "encoding/json" "fmt" "io" "net/http" "sync" "time" "github.com/prism/proxy/internal/cache" "github.com/prism/proxy/internal/mihomoapi" "github.com/prism/proxy/internal/store" ) type Collector struct { store *store.Store cache *cache.Cache apiBase string secret string client *http.Client mu sync.Mutex connCache []map[string]any connCacheAt time.Time refreshing bool } func NewCollector(s *store.Store, c *cache.Cache, apiBase, secret string) *Collector { return &Collector{ store: s, cache: c, apiBase: trimRight(apiBase, "/"), secret: secret, client: &http.Client{ Timeout: 3 * time.Second, }, } } func trimRight(s, cut string) string { for len(s) > 0 && len(s) >= len(cut) && s[len(s)-len(cut):] == cut { s = s[:len(s)-len(cut)] } return s } // Snapshot returns cached dashboard data and never blocks on Mihomo. func (m *Collector) Snapshot(ctx context.Context) cache.DashboardSnapshot { snap := m.cache.GetDashboard() total, alive, _ := m.store.CountEnabledProxies(ctx) snap.TotalNodes = total snap.AliveNodes = alive return snap } func (m *Collector) RefreshIfStale(ctx context.Context, maxAge time.Duration) cache.DashboardSnapshot { snap := m.cache.GetDashboard() if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge { return snap } refreshed, err := m.Refresh(ctx) if err != nil { return snap } return refreshed } func (m *Collector) RefreshAsync(maxAge time.Duration) { snap := m.cache.GetDashboard() if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge { return } m.mu.Lock() if m.refreshing { m.mu.Unlock() return } m.refreshing = true m.mu.Unlock() go func() { defer func() { m.mu.Lock() m.refreshing = false m.mu.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) defer cancel() _, _ = m.Refresh(ctx) }() } func (m *Collector) Refresh(ctx context.Context) (cache.DashboardSnapshot, error) { var snap cache.DashboardSnapshot snap.UpdatedAt = time.Now() var ( traffic TrafficRatesResult conns map[string]any wg sync.WaitGroup ) wg.Add(2) go func() { defer wg.Done() rates, err := mihomoapi.New(m.apiBase, m.secret).TrafficRates(ctx) if err == nil { traffic = TrafficRatesResult{Up: rates.Up, Down: rates.Down, OK: true} } }() go func() { defer wg.Done() conns, _ = m.getJSON("/connections") }() wg.Wait() if traffic.OK { snap.UploadRate = traffic.Up snap.DownloadRate = traffic.Down } if conns != nil { if arr, ok := conns["connections"].([]any); ok { snap.Connections = len(arr) m.setConnCache(arr) } } total, alive, _ := m.store.CountEnabledProxies(ctx) snap.TotalNodes = total snap.AliveNodes = alive m.cache.SetDashboard(snap) _ = m.store.AddTrafficSnapshot(ctx, snap.UploadRate, snap.DownloadRate, int64(snap.Connections)) return snap, nil } func (m *Collector) setConnCache(arr []any) { out := make([]map[string]any, 0, len(arr)) for _, item := range arr { if m, ok := item.(map[string]any); ok { out = append(out, m) } } m.mu.Lock() m.connCache = out m.connCacheAt = time.Now() m.mu.Unlock() } func (m *Collector) CachedConnections() []map[string]any { m.mu.Lock() defer m.mu.Unlock() return copyConnSlice(m.connCache) } func (m *Collector) GetConnections() ([]map[string]any, error) { return m.GetConnectionsCached(0) } func (m *Collector) GetConnectionsCached(maxAge time.Duration) ([]map[string]any, error) { m.mu.Lock() if maxAge > 0 && m.connCache != nil && time.Since(m.connCacheAt) < maxAge { out := copyConnSlice(m.connCache) m.mu.Unlock() return out, nil } m.mu.Unlock() m.RefreshAsync(maxAge) if cached := m.CachedConnections(); len(cached) > 0 { return cached, nil } data, err := m.getJSON("/connections") if err != nil { m.mu.Lock() defer m.mu.Unlock() if m.connCache != nil { return copyConnSlice(m.connCache), nil } return nil, err } arr, ok := data["connections"].([]any) if !ok { return nil, nil } m.setConnCache(arr) return m.CachedConnections(), nil } func copyConnSlice(in []map[string]any) []map[string]any { if len(in) == 0 { return nil } out := make([]map[string]any, len(in)) copy(out, in) return out } type TrafficRatesResult struct { Up int64 Down int64 OK bool } func (m *Collector) UpdateAPIBase(apiBase, secret string) { m.apiBase = trimRight(apiBase, "/") m.secret = secret } func (m *Collector) getJSON(path string) (map[string]any, error) { req, err := http.NewRequest(http.MethodGet, m.apiBase+path, nil) if err != nil { return nil, err } if m.secret != "" { req.Header.Set("Authorization", "Bearer "+m.secret) } resp, err := m.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 400 { return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body)) } var out map[string]any if err := json.Unmarshal(body, &out); err != nil { return nil, err } return out, nil }